@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 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
+ [![npm version](https://img.shields.io/npm/v/@jekrch/react-viewport-lightbox.svg)](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
4
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@jekrch/react-viewport-lightbox)](https://bundlephobia.com/package/@jekrch/react-viewport-lightbox)
5
+ [![coverage](https://codecov.io/gh/jekrch/react-viewport-lightbox/branch/main/graph/badge.svg)](https://codecov.io/gh/jekrch/react-viewport-lightbox)
6
+ [![types](https://img.shields.io/npm/types/@jekrch/react-viewport-lightbox.svg)](https://www.npmjs.com/package/@jekrch/react-viewport-lightbox)
7
+ [![license](https://img.shields.io/npm/l/@jekrch/react-viewport-lightbox.svg)](./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