astro-zoom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # astro-zoom
2
+
3
+ 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.
4
+
5
+ - **~2.8 KB** shipped (JS + CSS) — ~1.4 KB gzipped
6
+ - Uses Astro's `<Picture>` pipeline — avif/webp, optimised srcsets, built at compile time
7
+ - `<dialog>` based — no z-index battles, native Escape handling, accessible
8
+ - ClientRouter compatible
9
+ - Supports captions: brief on the page, expanded in the modal
10
+ - `prefers-reduced-motion` aware
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ npx astro add astro-zoom
16
+ # or
17
+ pnpm add astro-zoom
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ There are two ways to use astro-zoom.
23
+
24
+ ### Option A — `<AstroZoom>` wrapper (recommended)
25
+
26
+ 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.
27
+
28
+ ```astro
29
+ ---
30
+ import { AstroZoom } from 'astro-zoom'
31
+ import photo from '../assets/photo.jpg'
32
+ ---
33
+
34
+ <AstroZoom
35
+ src={photo}
36
+ alt="A descriptive alt text"
37
+ caption="Brief caption shown on the page"
38
+ modalCaption="An expanded description shown inside the zoomed modal."
39
+ />
40
+ ```
41
+
42
+ ### Option B — `<AstroZoomInit>` + `data-zoom` (medium-zoom drop-in)
43
+
44
+ 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.
45
+
46
+ ```astro
47
+ ---
48
+ // Layout.astro
49
+ import { AstroZoomInit } from 'astro-zoom'
50
+ ---
51
+ <html>
52
+ <body>
53
+ <slot />
54
+ <AstroZoomInit />
55
+ </body>
56
+ </html>
57
+ ```
58
+
59
+ Then on any page:
60
+
61
+ ```astro
62
+ ---
63
+ import { Picture } from 'astro:assets'
64
+ import photo from '../assets/photo.jpg'
65
+ ---
66
+
67
+ <Picture src={photo} alt="Panda" formats={['avif', 'webp']} data-zoom />
68
+ ```
69
+
70
+ Or in markdown/MDX:
71
+
72
+ ```md
73
+ <img src="/images/photo.jpg" alt="Description" data-zoom />
74
+ ```
75
+
76
+ To zoom to a different (higher-res) source, use `data-zoom-src`:
77
+
78
+ ```html
79
+ <img src="thumb.jpg" alt="Description" data-zoom data-zoom-src="full.jpg" />
80
+ ```
81
+
82
+ Click the image to zoom. Click the backdrop or press Escape to close.
83
+
84
+ ## Props
85
+
86
+ ### `<AstroZoom>`
87
+
88
+ | Prop | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `src` | `ImageMetadata` | required | Image imported via Astro's asset pipeline |
91
+ | `alt` | `string` | required | Alt text for both thumbnail and modal image |
92
+ | `caption` | `string` | — | Brief caption shown as `<figcaption>` under the thumbnail |
93
+ | `modalCaption` | `string` | — | Expanded caption shown at the bottom of the modal |
94
+ | `thumbnailWidth` | `number` | natural width | Width of the thumbnail in pixels |
95
+ | `margin` | `number` | `40` | Minimum gap in pixels between the zoomed image and the viewport edge |
96
+ | `background` | `string` | `color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent)` | Modal backdrop colour |
97
+ | `duration` | `number` | `0.3` | Animation duration in seconds |
98
+ | `class` | `string` | — | CSS class applied to the outer `<figure>` element |
99
+
100
+ ### `<AstroZoomInit>`
101
+
102
+ | Prop | Type | Default | Description |
103
+ |---|---|---|---|
104
+ | `margin` | `number` | `40` | Minimum gap in pixels between the zoomed image and the viewport edge |
105
+ | `background` | `string` | `color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent)` | Modal backdrop colour |
106
+ | `duration` | `number` | `0.3` | Animation duration in seconds |
107
+
108
+ ## Examples
109
+
110
+ ### Image without captions
111
+
112
+ ```astro
113
+ <AstroZoom src={photo} alt="Mountain landscape" />
114
+ ```
115
+
116
+ ### Page caption only
117
+
118
+ ```astro
119
+ <AstroZoom
120
+ src={photo}
121
+ alt="Mountain landscape"
122
+ caption="Cairngorms National Park, Scotland"
123
+ />
124
+ ```
125
+
126
+ ### Both captions
127
+
128
+ ```astro
129
+ <AstroZoom
130
+ src={photo}
131
+ alt="Mountain landscape"
132
+ caption="Cairngorms National Park"
133
+ modalCaption="The Cairngorms form the largest arctic mountain plateau in the UK, with five of Scotland's six highest peaks."
134
+ />
135
+ ```
136
+
137
+ ### Custom appearance
138
+
139
+ ```astro
140
+ <AstroZoom
141
+ src={photo}
142
+ alt="Portrait"
143
+ background="rgba(0, 0, 0, 0.9)"
144
+ duration={0.4}
145
+ margin={60}
146
+ />
147
+ ```
148
+
149
+ ### Thumbnail width
150
+
151
+ ```astro
152
+ <AstroZoom src={photo} alt="Detail shot" thumbnailWidth={600} />
153
+ ```
154
+
155
+ ### In a grid
156
+
157
+ ```astro
158
+ ---
159
+ import { AstroZoom } from 'astro-zoom'
160
+ import img1 from '../assets/img1.jpg'
161
+ import img2 from '../assets/img2.jpg'
162
+ import img3 from '../assets/img3.jpg'
163
+ ---
164
+
165
+ <div class="grid">
166
+ <AstroZoom src={img1} alt="Image one" />
167
+ <AstroZoom src={img2} alt="Image two" />
168
+ <AstroZoom src={img3} alt="Image three" />
169
+ </div>
170
+ ```
171
+
172
+ ## Sizing thumbnails
173
+
174
+ `<AstroZoom>` renders as a `<figure>` element. To control how thumbnails fill their containers, target `.az-trigger img` in your own CSS:
175
+
176
+ ```css
177
+ .az-trigger img {
178
+ display: block;
179
+ width: 100%;
180
+ height: auto;
181
+ }
182
+ ```
183
+
184
+ ## Events
185
+
186
+ Both components dispatch custom events on the trigger `<img>` element, bubbling up the DOM.
187
+
188
+ | Event | Fires |
189
+ |---|---|
190
+ | `astro-zoom:open` | When zoom begins (before animation) |
191
+ | `astro-zoom:opened` | After the zoom-in animation completes |
192
+ | `astro-zoom:close` | When close begins (before animation) |
193
+ | `astro-zoom:closed` | After the dialog closes |
194
+
195
+ ```js
196
+ // On a specific image
197
+ document.querySelector('#my-photo').addEventListener('astro-zoom:opened', () => {
198
+ console.log('zoomed in')
199
+ })
200
+
201
+ // Or with event delegation
202
+ document.addEventListener('astro-zoom:closed', (e) => {
203
+ analytics.track('image_zoomed', { src: e.target.src })
204
+ })
205
+ ```
206
+
207
+ ## ClientRouter
208
+
209
+ Both components are compatible with Astro's ClientRouter (view transitions). No extra configuration needed.
210
+
211
+ `<AstroZoom>` re-initialises automatically on each `astro:page-load` event, picking up any new instances rendered on the incoming page.
212
+
213
+ `<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.
214
+
215
+ ```astro
216
+ ---
217
+ // Layout.astro
218
+ import { ViewTransitions } from 'astro:transitions'
219
+ import { AstroZoomInit } from 'astro-zoom'
220
+ ---
221
+ <html>
222
+ <head>
223
+ <ViewTransitions />
224
+ </head>
225
+ <body>
226
+ <slot />
227
+ <AstroZoomInit /> <!-- outside <slot />, persists across navigations -->
228
+ </body>
229
+ </html>
230
+ ```
231
+
232
+ ## How it works
233
+
234
+ Each `<AstroZoom>` instance renders a `<figure>` containing:
235
+ - A `<picture>` thumbnail (avif/webp, processed by Astro at build time)
236
+ - An optional `<figcaption>` for the page caption
237
+ - A `<dialog>` containing a full-size `<picture>` and optional modal caption
238
+
239
+ 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()`.
240
+
241
+ No runtime DOM construction. No external dependencies beyond Astro itself.
242
+
243
+ ## Comparing to medium-zoom
244
+
245
+ | | astro-zoom | medium-zoom |
246
+ |---|---|---|
247
+ | JS (minified) | 1.6 KB | 9.4 KB |
248
+ | JS (gzipped) | 0.8 KB | 3.0 KB |
249
+ | Astro `<Image>` / `<Picture>` | ✅ | ✗ |
250
+ | Built at compile time | ✅ | ✗ |
251
+ | `<dialog>` based | ✅ | ✗ |
252
+ | ClientRouter support | ✅ | ✗ |
253
+
254
+ ## Compatibility
255
+
256
+ - Astro 5, 6, 7
257
+ - Node 22.12.0+
258
+ - All modern browsers (uses `<dialog>`, `dvw`/`dvh`, `::backdrop`)
259
+
260
+ ## Credits
261
+
262
+ Inspired by [medium-zoom](https://github.com/francoischalifour/medium-zoom) by François Chalifour — the `data-zoom` API and event naming follow its conventions.
263
+
264
+ 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](https://github.com/SaintSin/astro-pandabox), a full lightbox/gallery component for Astro.
265
+
266
+ ## License
267
+
268
+ MIT
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Do not write code directly here, instead use the `src` folder.
2
+ // This file exports everything users can access from the package.
3
+
4
+ export { default as AstroZoom } from "./src/components/AstroZoom.astro";
5
+ export { default as AstroZoomInit } from "./src/components/AstroZoomInit.astro";
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "astro-zoom",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A lightweight zoom component for Astro. Uses Astro's Image pipeline, <3KB shipped.",
6
+ "author": {
7
+ "name": "StJohn Mackay",
8
+ "url": "https://github.com/SaintSin"
9
+ },
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/SaintSin/astro-zoom"
14
+ },
15
+ "homepage": "https://github.com/SaintSin/astro-zoom",
16
+ "keywords": [
17
+ "astro",
18
+ "astro-component",
19
+ "zoom",
20
+ "image",
21
+ "lightbox",
22
+ "lightweight",
23
+ "withastro"
24
+ ],
25
+ "exports": {
26
+ ".": "./index.ts"
27
+ },
28
+ "files": [
29
+ "src/components/AstroZoom.astro",
30
+ "src/components/AstroZoomInit.astro",
31
+ "index.ts"
32
+ ],
33
+ "devDependencies": {
34
+ "@astrojs/sitemap": "^3.7.3",
35
+ "@biomejs/biome": "2.5.1",
36
+ "@felixicaza/astro-capo": "^1.2.1",
37
+ "astro": "^7.0.2",
38
+ "astro-robots-txt": "^1.0.0",
39
+ "open-props": "^1.7.23",
40
+ "postcss-jit-props": "^1.0.16",
41
+ "sharp": "^0.35.2"
42
+ },
43
+ "peerDependencies": {
44
+ "astro": " ^5.0.0 || ^6.0.0 || ^7.0.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.12.0"
48
+ },
49
+ "scripts": {
50
+ "dev": "astro dev",
51
+ "build": "astro build",
52
+ "preview": "astro preview",
53
+ "format": "biome format --write .",
54
+ "check": "biome check --write .",
55
+ "clean": "rm -rf dist .astro",
56
+ "purge": "rm -rf dist .astro node_modules"
57
+ }
58
+ }
@@ -0,0 +1,243 @@
1
+ ---
2
+ import { Picture } from "astro:assets";
3
+ import type { ImageMetadata } from "astro";
4
+
5
+ interface Props {
6
+ src: ImageMetadata;
7
+ alt: string;
8
+ caption?: string;
9
+ modalCaption?: string;
10
+ thumbnailWidth?: number;
11
+ margin?: number;
12
+ background?: string;
13
+ duration?: number;
14
+ class?: string;
15
+ }
16
+
17
+ const {
18
+ src,
19
+ alt,
20
+ caption,
21
+ modalCaption,
22
+ thumbnailWidth,
23
+ margin = 40,
24
+ background = "color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent)",
25
+ duration = 0.3,
26
+ class: className,
27
+ } = Astro.props;
28
+ ---
29
+
30
+ <figure class:list={['az-trigger', className]} data-az-trigger>
31
+ <Picture
32
+ src={src}
33
+ alt={alt}
34
+ formats={['avif', 'webp']}
35
+ width={thumbnailWidth ?? src.width}
36
+ data-az-thumb
37
+ />
38
+ {caption && <figcaption>{caption}</figcaption>}
39
+ <dialog
40
+ class="az-dialog"
41
+ aria-label={alt}
42
+ data-az-w={src.width}
43
+ data-az-h={src.height}
44
+ style={`--az-margin:${margin}px;--az-bg:${background};--az-duration:${duration}s`}
45
+ >
46
+ <Picture src={src} alt={alt} formats={['avif', 'webp']} loading="eager" />
47
+ {modalCaption && <p class="az-modal-caption">{modalCaption}</p>}
48
+ </dialog>
49
+ </figure>
50
+
51
+ <style is:global>
52
+ .az-trigger {
53
+ cursor: zoom-in;
54
+ }
55
+
56
+ .az-trigger figcaption {
57
+ margin-top: 0.5em;
58
+ font-size: 0.875em;
59
+ color: inherit;
60
+ opacity: 0.7;
61
+ }
62
+
63
+ .az-modal-caption {
64
+ position: absolute;
65
+ bottom: 0;
66
+ left: 0;
67
+ right: 0;
68
+ padding: 1.5rem;
69
+ margin: 0;
70
+ text-align: center;
71
+ opacity: 0;
72
+ transition: opacity var(--az-duration, 0.3s) ease;
73
+ }
74
+
75
+ .az-dialog[open] .az-modal-caption {
76
+ opacity: 1;
77
+ }
78
+
79
+ .az-dialog.az-closing .az-modal-caption {
80
+ opacity: 0;
81
+ }
82
+
83
+ .az-dialog {
84
+ position: fixed;
85
+ inset: 0;
86
+ margin: 0;
87
+ width: 100dvw;
88
+ height: 100dvh;
89
+ max-width: 100dvw;
90
+ max-height: 100dvh;
91
+ padding: 0;
92
+ border: none;
93
+ background: transparent;
94
+ overflow: hidden;
95
+ }
96
+
97
+ .az-dialog::backdrop {
98
+ background: var(--az-bg, color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent));
99
+ opacity: 0;
100
+ transition: opacity var(--az-duration, 0.3s) ease;
101
+ }
102
+
103
+ .az-dialog[open]::backdrop {
104
+ opacity: 1;
105
+ }
106
+
107
+ .az-dialog.az-closing::backdrop {
108
+ opacity: 0;
109
+ }
110
+
111
+ .az-dialog img {
112
+ position: absolute;
113
+ top: 0;
114
+ left: 0;
115
+ width: auto;
116
+ height: auto;
117
+ max-width: calc(100dvw - 2 * var(--az-margin, 40px));
118
+ max-height: calc(100dvh - 2 * var(--az-margin, 40px));
119
+ transform-origin: top left;
120
+ animation: az-zoom-in var(--az-duration, 0.3s) ease forwards;
121
+ }
122
+
123
+ @media (prefers-reduced-motion: reduce) {
124
+ .az-dialog img {
125
+ animation-duration: 0.001s;
126
+ }
127
+ }
128
+
129
+ @keyframes az-zoom-in {
130
+ from {
131
+ transform: translate(var(--initial-x, 0px), var(--initial-y, 0px)) scale(var(--initial-scale, 1));
132
+ }
133
+ to {
134
+ transform: translate(var(--final-x, 0px), var(--final-y, 0px)) scale(1);
135
+ }
136
+ }
137
+
138
+ @keyframes az-zoom-out {
139
+ from {
140
+ transform: translate(var(--final-x, 0px), var(--final-y, 0px)) scale(1);
141
+ }
142
+ to {
143
+ transform: translate(var(--initial-x, 0px), var(--initial-y, 0px)) scale(var(--initial-scale, 1));
144
+ }
145
+ }
146
+ </style>
147
+
148
+ <script>
149
+ const closing = new WeakSet<HTMLDialogElement>()
150
+
151
+ function dispatch(img: HTMLImageElement, name: string) {
152
+ img.dispatchEvent(new CustomEvent(`astro-zoom:${name}`, { bubbles: true }))
153
+ }
154
+
155
+ function open(thumbImg: HTMLImageElement, dialog: HTMLDialogElement) {
156
+ if (dialog.open) return
157
+
158
+ const rect = thumbImg.getBoundingClientRect()
159
+ const margin = parseFloat(getComputedStyle(dialog).getPropertyValue('--az-margin')) || 40
160
+ const natW = parseInt(dialog.dataset.azW ?? '0') || thumbImg.naturalWidth || rect.width
161
+ const natH = parseInt(dialog.dataset.azH ?? '0') || thumbImg.naturalHeight || rect.height
162
+
163
+ const scale = Math.min(
164
+ (window.innerWidth - margin * 2) / natW,
165
+ (window.innerHeight - margin * 2) / natH,
166
+ 1
167
+ )
168
+ const dispW = natW * scale
169
+ const dispH = natH * scale
170
+
171
+ dialog.style.setProperty('--initial-x', `${rect.left}px`)
172
+ dialog.style.setProperty('--initial-y', `${rect.top}px`)
173
+ dialog.style.setProperty('--initial-scale', `${rect.width / dispW}`)
174
+ dialog.style.setProperty('--final-x', `${(window.innerWidth - dispW) / 2}px`)
175
+ dialog.style.setProperty('--final-y', `${(window.innerHeight - dispH) / 2}px`)
176
+
177
+ // Force animation restart on repeated opens
178
+ const dialogImg = dialog.querySelector<HTMLImageElement>('img')
179
+ if (dialogImg) {
180
+ dialogImg.style.animation = 'none'
181
+ void dialogImg.offsetWidth // trigger reflow
182
+ dialogImg.style.animation = ''
183
+ }
184
+
185
+ dispatch(thumbImg, 'open')
186
+ dialog.showModal()
187
+
188
+ const duration = parseFloat(getComputedStyle(dialog).getPropertyValue('--az-duration')) * 1000 || 300
189
+ setTimeout(() => dispatch(thumbImg, 'opened'), duration)
190
+ }
191
+
192
+ async function close(dialog: HTMLDialogElement) {
193
+ if (closing.has(dialog) || !dialog.open) return
194
+ closing.add(dialog)
195
+
196
+ const thumbImg = dialog.closest('[data-az-trigger]')?.querySelector<HTMLImageElement>('[data-az-thumb]')
197
+ const dialogImg = dialog.querySelector<HTMLImageElement>('img')
198
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
199
+ const duration = reducedMotion
200
+ ? 1
201
+ : parseFloat(getComputedStyle(dialog).getPropertyValue('--az-duration')) * 1000 || 300
202
+
203
+ if (thumbImg) dispatch(thumbImg, 'close')
204
+
205
+ if (dialogImg) {
206
+ dialogImg.style.animation = 'az-zoom-out var(--az-duration) ease forwards'
207
+ if (reducedMotion) dialogImg.style.animationDuration = '0.001s'
208
+ }
209
+ dialog.classList.add('az-closing')
210
+
211
+ await new Promise<void>((r) => setTimeout(r, duration))
212
+
213
+ dialog.close()
214
+ dialog.classList.remove('az-closing')
215
+ if (dialogImg) dialogImg.style.animation = ''
216
+ if (thumbImg) dispatch(thumbImg, 'closed')
217
+ closing.delete(dialog)
218
+ }
219
+
220
+ function init() {
221
+ document.querySelectorAll<HTMLElement>('[data-az-trigger]').forEach((trigger) => {
222
+ if (trigger.dataset.azInit) return
223
+ trigger.dataset.azInit = 'true'
224
+
225
+ const dialog = trigger.querySelector<HTMLDialogElement>('.az-dialog')
226
+ // data-az-thumb lands on the <img> inside <Picture> (Astro passes extra attrs to the img)
227
+ const thumbImg = trigger.querySelector<HTMLImageElement>('[data-az-thumb]')
228
+ if (!dialog || !thumbImg) return
229
+
230
+ thumbImg.addEventListener('click', () => open(thumbImg, dialog))
231
+ dialog.addEventListener('click', (e) => {
232
+ if (e.target === dialog) close(dialog)
233
+ })
234
+ dialog.addEventListener('cancel', (e) => {
235
+ e.preventDefault()
236
+ close(dialog)
237
+ })
238
+ })
239
+ }
240
+
241
+ document.addEventListener('DOMContentLoaded', init)
242
+ document.addEventListener('astro:page-load', init)
243
+ </script>
@@ -0,0 +1,189 @@
1
+ ---
2
+ interface Props {
3
+ margin?: number;
4
+ background?: string;
5
+ duration?: number;
6
+ }
7
+
8
+ const {
9
+ margin = 40,
10
+ background = "color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent)",
11
+ duration = 0.3,
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <dialog
16
+ class="az-dialog"
17
+ data-az-singleton
18
+ aria-label="Zoomed image"
19
+ style={`--az-margin:${margin}px;--az-bg:${background};--az-duration:${duration}s`}
20
+ >
21
+ <img class="az-img" src="" alt="" />
22
+ </dialog>
23
+
24
+ <style is:global>
25
+ img[data-zoom] {
26
+ cursor: zoom-in;
27
+ }
28
+
29
+ .az-dialog {
30
+ position: fixed;
31
+ inset: 0;
32
+ margin: 0;
33
+ width: 100dvw;
34
+ height: 100dvh;
35
+ max-width: 100dvw;
36
+ max-height: 100dvh;
37
+ padding: 0;
38
+ border: none;
39
+ background: transparent;
40
+ overflow: hidden;
41
+ }
42
+
43
+ .az-dialog::backdrop {
44
+ background: var(--az-bg, color-mix(in oklch, var(--color-bg, oklch(99% 0 0)) 95%, transparent));
45
+ opacity: 0;
46
+ transition: opacity var(--az-duration, 0.3s) ease;
47
+ }
48
+
49
+ .az-dialog[open]::backdrop {
50
+ opacity: 1;
51
+ }
52
+
53
+ .az-dialog.az-closing::backdrop {
54
+ opacity: 0;
55
+ }
56
+
57
+ .az-dialog img {
58
+ position: absolute;
59
+ top: 0;
60
+ left: 0;
61
+ width: auto;
62
+ height: auto;
63
+ max-width: calc(100dvw - 2 * var(--az-margin, 40px));
64
+ max-height: calc(100dvh - 2 * var(--az-margin, 40px));
65
+ transform-origin: top left;
66
+ animation: az-zoom-in var(--az-duration, 0.3s) ease forwards;
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .az-dialog img {
71
+ animation-duration: 0.001s;
72
+ }
73
+ }
74
+
75
+ @keyframes az-zoom-in {
76
+ from {
77
+ transform: translate(var(--initial-x, 0px), var(--initial-y, 0px)) scale(var(--initial-scale, 1));
78
+ }
79
+ to {
80
+ transform: translate(var(--final-x, 0px), var(--final-y, 0px)) scale(1);
81
+ }
82
+ }
83
+
84
+ @keyframes az-zoom-out {
85
+ from {
86
+ transform: translate(var(--final-x, 0px), var(--final-y, 0px)) scale(1);
87
+ }
88
+ to {
89
+ transform: translate(var(--initial-x, 0px), var(--initial-y, 0px)) scale(var(--initial-scale, 1));
90
+ }
91
+ }
92
+ </style>
93
+
94
+ <script>
95
+ const dialog = document.querySelector<HTMLDialogElement>('dialog[data-az-singleton]')
96
+ const dialogImg = dialog?.querySelector<HTMLImageElement>('img')
97
+ let closing = false
98
+ let currentTrigger: HTMLImageElement | null = null
99
+
100
+ function dispatch(name: string) {
101
+ currentTrigger?.dispatchEvent(new CustomEvent(`astro-zoom:${name}`, { bubbles: true }))
102
+ }
103
+
104
+ function open(trigger: HTMLImageElement) {
105
+ if (!dialog || !dialogImg || dialog.open) return
106
+
107
+ currentTrigger = trigger
108
+ const rect = trigger.getBoundingClientRect()
109
+ dialogImg.src = trigger.dataset.zoomSrc || trigger.currentSrc || trigger.src
110
+ dialogImg.alt = trigger.alt
111
+
112
+ const margin = parseFloat(getComputedStyle(dialog).getPropertyValue('--az-margin')) || 40
113
+ const natW = trigger.naturalWidth || rect.width
114
+ const natH = trigger.naturalHeight || rect.height
115
+
116
+ const scale = Math.min(
117
+ (window.innerWidth - margin * 2) / natW,
118
+ (window.innerHeight - margin * 2) / natH,
119
+ 1
120
+ )
121
+ const dispW = natW * scale
122
+ const dispH = natH * scale
123
+
124
+ dialog.style.setProperty('--initial-x', `${rect.left}px`)
125
+ dialog.style.setProperty('--initial-y', `${rect.top}px`)
126
+ dialog.style.setProperty('--initial-scale', `${rect.width / dispW}`)
127
+ dialog.style.setProperty('--final-x', `${(window.innerWidth - dispW) / 2}px`)
128
+ dialog.style.setProperty('--final-y', `${(window.innerHeight - dispH) / 2}px`)
129
+
130
+ dialogImg.style.width = `${dispW}px`
131
+ dialogImg.style.height = `${dispH}px`
132
+
133
+ // Force animation restart on repeated opens
134
+ dialogImg.style.animation = 'none'
135
+ void dialogImg.offsetWidth
136
+ dialogImg.style.animation = ''
137
+
138
+ dispatch('open')
139
+ dialog.showModal()
140
+
141
+ const duration = parseFloat(getComputedStyle(dialog).getPropertyValue('--az-duration')) * 1000 || 300
142
+ setTimeout(() => dispatch('opened'), duration)
143
+ }
144
+
145
+ async function close() {
146
+ if (!dialog || !dialogImg || closing || !dialog.open) return
147
+ closing = true
148
+
149
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
150
+ const duration = reducedMotion
151
+ ? 1
152
+ : parseFloat(getComputedStyle(dialog).getPropertyValue('--az-duration')) * 1000 || 300
153
+
154
+ dispatch('close')
155
+
156
+ dialogImg.style.animation = 'az-zoom-out var(--az-duration) ease forwards'
157
+ if (reducedMotion) dialogImg.style.animationDuration = '0.001s'
158
+ dialog.classList.add('az-closing')
159
+
160
+ await new Promise<void>((r) => setTimeout(r, duration))
161
+
162
+ dialog.close()
163
+ dialog.classList.remove('az-closing')
164
+ dialogImg.style.animation = ''
165
+ dispatch('closed')
166
+ closing = false
167
+ currentTrigger = null
168
+ }
169
+
170
+ function init() {
171
+ if (!dialog) return
172
+
173
+ // Guard dialog listeners — only attach once, they persist across ClientRouter navigations
174
+ if (!dialog.dataset.azInit) {
175
+ dialog.dataset.azInit = 'true'
176
+ dialog.addEventListener('click', (e) => { if (e.target === dialog) close() })
177
+ dialog.addEventListener('cancel', (e) => { e.preventDefault(); close() })
178
+ }
179
+
180
+ document.querySelectorAll<HTMLImageElement>('img[data-zoom]').forEach((img) => {
181
+ if (img.dataset.zoomInit) return
182
+ img.dataset.zoomInit = 'true'
183
+ img.addEventListener('click', () => open(img))
184
+ })
185
+ }
186
+
187
+ document.addEventListener('DOMContentLoaded', init)
188
+ document.addEventListener('astro:page-load', init)
189
+ </script>