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 +268 -0
- package/index.ts +5 -0
- package/package.json +58 -0
- package/src/components/AstroZoom.astro +243 -0
- package/src/components/AstroZoomInit.astro +189 -0
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>
|