astro-zoom
A lightweight zoom component for Astro. Click any image to zoom it to the center of the viewport with a smooth animation, then close with a click or Escape.
- ~2.8 KB shipped (JS + CSS) — ~1.4 KB gzipped
- Uses Astro's
<Picture>pipeline — avif/webp, optimised srcsets, built at compile time <dialog>based — no z-index battles, native Escape handling, accessible- ClientRouter compatible
- Supports captions: brief on the page, expanded in the modal
prefers-reduced-motionaware
Installation
pnpm add astro-zoom
# or
npm install astro-zoom
Usage
There are two ways to use astro-zoom.
Option A — <AstroZoom> wrapper (recommended)
The Astro-native approach. Wrap each image with <AstroZoom> — it renders a <Picture> thumbnail and a full-resolution <Picture> inside the dialog, both processed by Astro's image pipeline at build time.
---
import { AstroZoom } from 'astro-zoom'
import photo from '../assets/photo.jpg'
---
<AstroZoom
src={photo}
alt="A descriptive alt text"
caption="Brief caption shown on the page"
modalCaption="An expanded description shown inside the zoomed modal."
/>
Option B — <AstroZoomInit> + data-zoom (medium-zoom drop-in)
Add <AstroZoomInit /> once to your layout, then add data-zoom to any <img> on the page. Works with Astro's <Image> and <Picture> components, plain <img> tags, and images from a CMS or markdown.
---
// Layout.astro
import { AstroZoomInit } from 'astro-zoom'
---
<html>
<body>
<slot />
<AstroZoomInit />
</body>
</html>
Then on any page:
---
import { Picture } from 'astro:assets'
import photo from '../assets/photo.jpg'
---
<Picture src={photo} alt="Panda" formats={['avif', 'webp']} data-zoom />
Or in markdown/MDX:
<img src="/images/photo.jpg" alt="Description" data-zoom />
To zoom to a different (higher-res) source, use data-zoom-src:
<img src="thumb.jpg" alt="Description" data-zoom data-zoom-src="full.jpg" />
Click the image to zoom. Click anywhere in the modal or press Escape to close.
Add data-caption for a caption shown in the modal, and per-image data-margin, data-background, or data-duration to override the component defaults for that image:
<img
src="/images/photo.jpg"
alt="Description"
data-zoom
data-caption="Caption shown in the modal"
data-background="rgba(139, 0, 0, 0.9)"
data-duration="0.5"
/>
Props
<AstroZoom>
| Prop | Type | Default | Description |
|---|---|---|---|
src |
ImageMetadata |
required | Image imported via Astro's asset pipeline |
alt |
string |
required | Alt text for both thumbnail and modal image |
caption |
string |
— | Brief caption shown as <figcaption> under the thumbnail |
modalCaption |
string |
— | Expanded caption shown at the bottom of the modal |
thumbnailWidth |
number |
natural width | Width of the thumbnail in pixels |
margin |
number |
40 |
Minimum gap in pixels between the zoomed image and the viewport edge |
background |
string |
var(--color-bg, oklch(99% 0 0)) |
Modal backdrop colour |
duration |
number |
0.3 |
Animation duration in seconds |
class |
string |
— | CSS class applied to the outer <figure> element |
imageClass |
string |
— | CSS class applied to the thumbnail <img> element |
<AstroZoomInit>
Set once on the singleton component — applies to every zoomed image on the page.
| Prop | Type | Default | Description |
|---|---|---|---|
margin |
number |
40 |
Minimum gap in pixels between the zoomed image and the viewport edge |
background |
string |
var(--color-bg, oklch(99% 0 0)) |
Modal backdrop colour |
duration |
number |
0.3 |
Animation duration in seconds |
Per-image data-* attributes
Set on each trigger image — not on <AstroZoomInit>.
| Attribute | Description |
|---|---|
data-zoom |
Marks the image as zoomable |
data-caption |
Caption shown inside the modal |
data-zoom-src |
Separate high-res URL to zoom to instead of the thumbnail src |
data-margin |
Overrides margin for this image only (number, in px) |
data-background |
Overrides background for this image only |
data-duration |
Overrides duration for this image only (number, in seconds) |
Examples
Image without captions
<AstroZoom src={photo} alt="Mountain landscape" />
Page caption only
<AstroZoom
src={photo}
alt="Mountain landscape"
caption="Cairngorms National Park, Scotland"
/>
Both captions
<AstroZoom
src={photo}
alt="Mountain landscape"
caption="Cairngorms National Park"
modalCaption="The Cairngorms form the largest arctic mountain plateau in the UK, with five of Scotland's six highest peaks."
/>
Custom appearance
<AstroZoom
src={photo}
alt="Portrait"
background="rgba(0, 0, 0, 0.9)"
duration={0.4}
margin={60}
/>
Thumbnail width
<AstroZoom src={photo} alt="Detail shot" thumbnailWidth={600} />
In a grid
---
import { AstroZoom } from 'astro-zoom'
import img1 from '../assets/img1.jpg'
import img2 from '../assets/img2.jpg'
import img3 from '../assets/img3.jpg'
---
<div class="grid">
<AstroZoom src={img1} alt="Image one" />
<AstroZoom src={img2} alt="Image two" />
<AstroZoom src={img3} alt="Image three" />
</div>
Sizing thumbnails
<AstroZoom> renders as a <figure> element. To control how thumbnails fill their containers, target .az-trigger img in your own CSS:
.az-trigger img {
display: block;
width: 100%;
height: auto;
}
Events
Both components dispatch custom events on the trigger <img> element, bubbling up the DOM.
| Event | Fires |
|---|---|
astro-zoom:open |
When zoom begins (before animation) |
astro-zoom:opened |
After the zoom-in animation completes |
astro-zoom:close |
When close begins (before animation) |
astro-zoom:closed |
After the dialog closes |
// On a specific image
document.querySelector('#my-photo').addEventListener('astro-zoom:opened', () => {
console.log('zoomed in')
})
// Or with event delegation
document.addEventListener('astro-zoom:closed', (e) => {
analytics.track('image_zoomed', { src: e.target.src })
})
ClientRouter
Both components are compatible with Astro's ClientRouter (view transitions). No extra configuration needed.
<AstroZoom> re-initialises automatically on each astro:page-load event, picking up any new instances rendered on the incoming page.
<AstroZoomInit> should be placed in your layout (outside the transitioning content) so the singleton dialog persists across navigations. The script attaches to new img[data-zoom] elements on each page load automatically.
---
// Layout.astro
import { ClientRouter } from 'astro:transitions'
import { AstroZoomInit } from 'astro-zoom'
---
<html>
<head>
<ClientRouter />
</head>
<body>
<slot />
<AstroZoomInit /> <!-- outside <slot />, persists across navigations -->
</body>
</html>
How it works
Each <AstroZoom> instance renders a <figure> containing:
- A
<picture>thumbnail (avif/webp, processed by Astro at build time) - An optional
<figcaption>for the page caption - A
<dialog>containing a full-size<picture>and optional modal caption
On click, JavaScript measures the thumbnail's position, sets CSS custom properties on the dialog, and calls showModal(). A transform-origin: top left CSS animation zooms the image from the thumbnail's position to the centre of the viewport. Closing plays the animation in reverse before dialog.close().
No runtime DOM construction. No external dependencies beyond Astro itself.
Comparing to medium-zoom
| astro-zoom | medium-zoom | |
|---|---|---|
| JS (minified) | 1.6 KB | 9.4 KB |
| JS (gzipped) | 0.8 KB | 3.0 KB |
Astro <Image> / <Picture> |
✗ | |
| Built at compile time | ✗ | |
<dialog> based |
✗ | |
| ClientRouter support | ✗ |
Compatibility
- Astro 5, 6, 7
- Node 22.12.0+
- All modern browsers (uses
<dialog>,dvw/dvh,::backdrop)
Credits
Inspired by medium-zoom by François Chalifour — the data-zoom API and event naming follow its conventions.
The zoom animation approach — transform-origin: top left, CSS custom properties for initial/final positions, and the <dialog>-based architecture — is derived from astro-pandabox, a full lightbox/gallery component for Astro.
License
MIT