@jekrch/react-viewport-lightbox 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/LICENSE +21 -0
- package/README.md +321 -0
- package/dist/index.cjs +1078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +277 -0
- package/dist/index.d.ts +277 -0
- package/dist/index.js +1059 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +344 -0
- package/package.json +89 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jacob Krch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# @jekrch/react-viewport-lightbox
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
|
|
4
|
+
[](https://bundlephobia.com/package/@jekrch/react-viewport-lightbox)
|
|
5
|
+
[](https://codecov.io/gh/jekrch/react-viewport-lightbox)
|
|
6
|
+
[](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
A touch-friendly React image viewer and lightbox with zoom, pan, pinch, and swipe.
|
|
10
|
+
It ships an `<ImageViewer>` shell with render slots for headers, footers, and
|
|
11
|
+
overlays (info drawers, graphs, and so on), and the interaction hooks are exported
|
|
12
|
+
if you'd rather build your own shell.
|
|
13
|
+
|
|
14
|
+
**[Live demo →](https://jekrch.github.io/react-viewport-lightbox/)**
|
|
15
|
+
|
|
16
|
+
- **Zero runtime dependencies.** React is a peer dependency; no Tailwind or icon
|
|
17
|
+
library required.
|
|
18
|
+
- **Touch and desktop.** Wheel/pinch zoom, drag/swipe navigation, double-tap and
|
|
19
|
+
double-click, keyboard arrows, rubber-band edges.
|
|
20
|
+
- **Themeable** via CSS custom properties and per-slot `className` overrides.
|
|
21
|
+
- **Accessible.** `role="dialog"` with focus trap and focus restore, labelled
|
|
22
|
+
controls, honors `prefers-reduced-motion`.
|
|
23
|
+
- **Headless.** The interaction hooks are exported for fully custom shells.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm add @jekrch/react-viewport-lightbox
|
|
29
|
+
# or: bun add @jekrch/react-viewport-lightbox
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Import the stylesheet once, anywhere in your app:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import "@jekrch/react-viewport-lightbox/styles.css";
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useState } from "react";
|
|
42
|
+
import { ImageViewer, type ViewerItem } from "@jekrch/react-viewport-lightbox";
|
|
43
|
+
import "@jekrch/react-viewport-lightbox/styles.css";
|
|
44
|
+
|
|
45
|
+
const items: ViewerItem[] = [
|
|
46
|
+
{ id: "1", src: "/photos/1.jpg", alt: "First" },
|
|
47
|
+
{ id: "2", src: "/photos/2.jpg", alt: "Second" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function Gallery() {
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [index, setIndex] = useState(0);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<button onClick={() => setOpen(true)}>Open</button>
|
|
57
|
+
{open && (
|
|
58
|
+
<ImageViewer
|
|
59
|
+
items={items}
|
|
60
|
+
index={index}
|
|
61
|
+
onIndexChange={setIndex}
|
|
62
|
+
onClose={() => setOpen(false)}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Mount the viewer when open and unmount it on close. It runs its own enter/exit
|
|
71
|
+
animation and calls `onClose` after the exit completes.
|
|
72
|
+
|
|
73
|
+
> **Image URLs are passed verbatim.** Resolve any base path (e.g.
|
|
74
|
+
> `import.meta.env.BASE_URL`) before putting it in `item.src`.
|
|
75
|
+
|
|
76
|
+
## Props
|
|
77
|
+
|
|
78
|
+
| Prop | Type | Default | Description |
|
|
79
|
+
| --------------------- | --------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `items` | `ViewerItem[]` | required | Images to display. |
|
|
81
|
+
| `index` | `number` | required | Controlled index of the active item. |
|
|
82
|
+
| `onIndexChange` | `(index: number) => void` | required | Called when navigation changes the index. |
|
|
83
|
+
| `onNavigate` | `(direction: "prev" \| "next") => void` | optional | Fired when a slide starts (before `onIndexChange`), so overlays can animate out in sync with the image. |
|
|
84
|
+
| `onClose` | `() => void` | required | Called after the exit animation completes. |
|
|
85
|
+
| `zoom` | `boolean` | `true` | Enable wheel/pinch/double-tap zoom + pan. |
|
|
86
|
+
| `showCounter` | `boolean` | `true` | Show the `index / total` counter. |
|
|
87
|
+
| `loop` | `boolean` | `false` | Wrap around at the ends (buttons + arrow keys). |
|
|
88
|
+
| `renderHeader` | `(ctx) => ReactNode` | optional | Top-left title area. |
|
|
89
|
+
| `renderHeaderActions` | `(ctx) => ReactNode` | optional | Extra top-right buttons (before Close). |
|
|
90
|
+
| `renderNavStart` | `(ctx) => ReactNode` | optional | Pinned to the left edge of the nav row (e.g. a details toggle); costs no extra height, nav group stays centered. |
|
|
91
|
+
| `renderNavEnd` | `(ctx) => ReactNode` | optional | Pinned to the right edge of the nav row. |
|
|
92
|
+
| `renderFooter` | `(ctx) => ReactNode` | optional | Content below the nav row. |
|
|
93
|
+
| `renderOverlay` | `(ctx) => ReactNode` | optional | Drawers and graphs layered over the image. |
|
|
94
|
+
| `classNames` | `Partial<Record<ViewerSlot, string>>` | optional | Per-slot `className` overrides. |
|
|
95
|
+
| `icons` | `Partial<ViewerIcons>` | optional | Override `close`, `zoomIn`, `zoomOut`, `prev`, and `next`. |
|
|
96
|
+
| `ariaLabel` | `string` | item `alt` | Dialog label. |
|
|
97
|
+
|
|
98
|
+
### `ViewerItem`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface ViewerItem<TData = unknown> {
|
|
102
|
+
id: string;
|
|
103
|
+
src: string; // final url
|
|
104
|
+
alt?: string;
|
|
105
|
+
thumbnail?: string; // falls back to src
|
|
106
|
+
data?: TData; // optional per-slide payload (see below)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Per-slide details
|
|
111
|
+
|
|
112
|
+
Anything richer than `alt` (a caption, credit line, tags, links) can live on the
|
|
113
|
+
item itself via the optional `data` field, instead of keeping a parallel array or
|
|
114
|
+
`id → details` map in sync as the index changes. Type it once and the viewer
|
|
115
|
+
passes it to every slot as `ctx.item.data`:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
interface Detail {
|
|
119
|
+
title: string;
|
|
120
|
+
body: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const items: ViewerItem<Detail>[] = [
|
|
124
|
+
{
|
|
125
|
+
id: "1",
|
|
126
|
+
src: "/photos/1.jpg",
|
|
127
|
+
alt: "First",
|
|
128
|
+
data: { title: "Sunrise", body: "Shot at dawn." },
|
|
129
|
+
},
|
|
130
|
+
{ id: "2", src: "/photos/2.jpg", alt: "Second", data: { title: "Dusk", body: "Golden hour." } },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
<ImageViewer
|
|
134
|
+
items={items} // TData is inferred, no annotation needed at the call site
|
|
135
|
+
index={index}
|
|
136
|
+
onIndexChange={setIndex}
|
|
137
|
+
onClose={() => setOpen(false)}
|
|
138
|
+
renderOverlay={(ctx) => (
|
|
139
|
+
// ctx.item.data is typed as Detail | undefined, and always matches the
|
|
140
|
+
// current slide, and updates as you navigate.
|
|
141
|
+
<div className="my-details">
|
|
142
|
+
<h2>{ctx.item.data?.title}</h2>
|
|
143
|
+
<p>{ctx.item.data?.body}</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
/>;
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`ViewerItem`, `ViewerContext`, and `ImageViewerProps` are all generic over `TData`
|
|
150
|
+
(defaulting to `unknown`), so this is fully type-safe and entirely opt-in.
|
|
151
|
+
|
|
152
|
+
## Slots & `ViewerContext`
|
|
153
|
+
|
|
154
|
+
Every `render*` slot receives a `ViewerContext` with navigation, zoom, and layout
|
|
155
|
+
state so slot content can coordinate with the viewer:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
interface ViewerContext<TData = unknown> {
|
|
159
|
+
items: ViewerItem<TData>[];
|
|
160
|
+
index: number;
|
|
161
|
+
item: ViewerItem<TData>; // item.data holds your per-slide payload
|
|
162
|
+
total: number;
|
|
163
|
+
|
|
164
|
+
hasPrev: boolean;
|
|
165
|
+
hasNext: boolean;
|
|
166
|
+
goPrev: () => void;
|
|
167
|
+
goNext: () => void;
|
|
168
|
+
goTo: (index: number) => void;
|
|
169
|
+
close: () => void;
|
|
170
|
+
|
|
171
|
+
isZoomed: boolean;
|
|
172
|
+
displayScale: number;
|
|
173
|
+
zoomIn: () => void;
|
|
174
|
+
zoomOut: () => void;
|
|
175
|
+
resetZoom: () => void;
|
|
176
|
+
|
|
177
|
+
isTouchDevice: boolean;
|
|
178
|
+
|
|
179
|
+
// Measured bar heights so overlays can size between the bars.
|
|
180
|
+
topBarHeight: number;
|
|
181
|
+
bottomBarHeight: number;
|
|
182
|
+
|
|
183
|
+
// Push the image track up/down (e.g. when a drawer opens). null resets.
|
|
184
|
+
// animate defaults to true; pass false to apply instantly (no transition).
|
|
185
|
+
setContentShift: (transform: string | null, animate?: boolean) => void;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Here's a Details toggle pinned left of the nav controls. Opening it slides the
|
|
190
|
+
image up (`setContentShift`) and the drawer up into its place. Navigating while it
|
|
191
|
+
is open slides the drawer out sideways and snaps the image back to center with
|
|
192
|
+
`setContentShift(null, false)` (no animation), so the next image slides straight in
|
|
193
|
+
horizontally instead of dropping from the top. Keep the drawer mounted so it
|
|
194
|
+
animates its own `transform`:
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
const [drawer, setDrawer] = useState(false);
|
|
198
|
+
const [slideDir, setSlideDir] = useState<"prev" | "next" | null>(null);
|
|
199
|
+
const shiftRef = useRef<ViewerContext["setContentShift"] | null>(null);
|
|
200
|
+
|
|
201
|
+
// After the slide lands, re-park the drawer at the bottom without animating
|
|
202
|
+
// (both positions are off-screen, so it snaps invisibly).
|
|
203
|
+
const [instant, setInstant] = useState(false);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (slideDir) {
|
|
206
|
+
setInstant(true);
|
|
207
|
+
setSlideDir(null);
|
|
208
|
+
}
|
|
209
|
+
}, [index]);
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!instant) return;
|
|
212
|
+
const r = requestAnimationFrame(() => setInstant(false));
|
|
213
|
+
return () => cancelAnimationFrame(r);
|
|
214
|
+
}, [instant]);
|
|
215
|
+
|
|
216
|
+
const transform = slideDir
|
|
217
|
+
? `translateX(${slideDir === "next" ? "-100%" : "100%"})`
|
|
218
|
+
: drawer
|
|
219
|
+
? "translateY(0)"
|
|
220
|
+
: "translateY(100vh)";
|
|
221
|
+
|
|
222
|
+
<ImageViewer
|
|
223
|
+
items={items}
|
|
224
|
+
index={index}
|
|
225
|
+
onIndexChange={setIndex}
|
|
226
|
+
onNavigate={(dir) => {
|
|
227
|
+
if (drawer) {
|
|
228
|
+
setSlideDir(dir);
|
|
229
|
+
setDrawer(false);
|
|
230
|
+
shiftRef.current?.(null, false); // snap image to center, no animation
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
onClose={() => setOpen(false)}
|
|
234
|
+
renderNavStart={(ctx) => (
|
|
235
|
+
<button
|
|
236
|
+
className={`rvl-btn${drawer ? " is-active" : ""}`}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
const next = !drawer;
|
|
239
|
+
setSlideDir(null);
|
|
240
|
+
setDrawer(next);
|
|
241
|
+
ctx.setContentShift(next ? "translateY(-100vh)" : null);
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{drawer ? "Hide details" : "Details"}
|
|
245
|
+
</button>
|
|
246
|
+
)}
|
|
247
|
+
renderOverlay={(ctx) => {
|
|
248
|
+
shiftRef.current = ctx.setContentShift;
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
style={{
|
|
252
|
+
position: "absolute",
|
|
253
|
+
left: 0,
|
|
254
|
+
right: 0,
|
|
255
|
+
top: ctx.topBarHeight, // size between the measured bars
|
|
256
|
+
bottom: ctx.bottomBarHeight,
|
|
257
|
+
// opaque, but pixel-identical to the area behind the image
|
|
258
|
+
background: `linear-gradient(var(--rvl-overlay-bg), var(--rvl-overlay-bg)), ${PAGE_BG}`,
|
|
259
|
+
transform,
|
|
260
|
+
transition: instant ? "none" : "transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1)",
|
|
261
|
+
pointerEvents: drawer ? "auto" : "none",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<MyDrawerContents item={ctx.item} />
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}}
|
|
268
|
+
/>;
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`renderNavStart` keeps the prev/counter/next group optically centered, so the
|
|
272
|
+
toggle adds no vertical space. This is the layout the plantyJ viewer uses. The image
|
|
273
|
+
snap on `onNavigate` is hidden because the opaque drawer is still covering it at that
|
|
274
|
+
instant. For a partial peek drawer, use a smaller animated shift like
|
|
275
|
+
`translateY(-40vh)` and skip the snap.
|
|
276
|
+
|
|
277
|
+
## Theming
|
|
278
|
+
|
|
279
|
+
Override any of these CSS custom properties (cascade into the viewer):
|
|
280
|
+
|
|
281
|
+
| Variable | Default |
|
|
282
|
+
| ------------------------------------- | ----------------- |
|
|
283
|
+
| `--rvl-accent` | `#4c538d` |
|
|
284
|
+
| `--rvl-overlay-bg` | `rgba(0,0,0,0.9)` |
|
|
285
|
+
| `--rvl-btn-bg` / `--rvl-btn-bg-hover` | translucent white |
|
|
286
|
+
| `--rvl-radius` | `4px` |
|
|
287
|
+
| `--rvl-anim-duration` | `250ms` |
|
|
288
|
+
|
|
289
|
+
```css
|
|
290
|
+
.my-gallery {
|
|
291
|
+
--rvl-accent: #ff5c8a;
|
|
292
|
+
--rvl-overlay-bg: rgba(10, 10, 20, 0.96);
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
For finer control, pass `classNames` to target individual slots (`root`, `backdrop`,
|
|
297
|
+
`topBar`, `bottomBar`, `image`, `button`, `counter`, `navButton`, `overlay`).
|
|
298
|
+
|
|
299
|
+
## Headless usage
|
|
300
|
+
|
|
301
|
+
The interaction engine is exported for building a fully custom shell:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import {
|
|
305
|
+
useImageZoomPan,
|
|
306
|
+
useSlideNavigation,
|
|
307
|
+
useGestureHandler,
|
|
308
|
+
useBarMeasure,
|
|
309
|
+
useBodyScrollLock,
|
|
310
|
+
useFocusTrap,
|
|
311
|
+
MIN_SCALE,
|
|
312
|
+
MAX_SCALE,
|
|
313
|
+
} from "@jekrch/react-viewport-lightbox";
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The pure geometry/threshold helpers (`clampTranslate`, `resolveSlideDirection`) are
|
|
317
|
+
exported too.
|
|
318
|
+
|
|
319
|
+
## License
|
|
320
|
+
|
|
321
|
+
MIT © jekrch
|