Lightbox Viewer
A keyboard-first image lightbox. Renders a focus-trapped dialog over a blurred backdrop with prev / next / close controls and arrow-key navigation. Supports controlled + uncontrolled open and activeIndex (the Radix value / defaultValue pair).
Preview
Click a thumbnail, then use the arrow keys to navigate.
Installation
npx shadcn@latest add https://craftbits.dev/r/lightbox-viewer.jsonUsage
Pass an images array — each entry carries an id, src, alt, and optional caption. Open the lightbox by setting open to true; navigate with the arrow keys, the on-screen buttons, or by driving activeIndex from the parent.
"use client";
import { useState } from "react";
import { LightboxViewer, type LightboxImage } from "@craft-bits/core";
const images: LightboxImage[] = [
{ id: "a", src: "/photos/a.jpg", alt: "Photo A" },
{ id: "b", src: "/photos/b.jpg", alt: "Photo B", caption: "Long exposure, 30s." },
];
export function Gallery() {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
return (
<>
<button type="button" onClick={() => { setIndex(0); setOpen(true); }}>
Open lightbox
</button>
<LightboxViewer
images={images}
open={open}
onOpenChange={setOpen}
activeIndex={index}
onActiveIndexChange={setIndex}
/>
</>
);
}Uncontrolled — let the lightbox own both the open state and the active index:
<LightboxViewer
images={images}
defaultOpen={false}
defaultActiveIndex={0}
/>Understanding the component
- Flat data, single dialog. A single
imagesarray drives the lightbox — each entry isid,src,alt, and optionalcaption. The component is one focus-trapped dialog, not a per-image overlay. - Controlled + uncontrolled, twice. Both
openandactiveIndexaccept the Radix value / defaultValue pair. PassdefaultOpen/defaultActiveIndexfor self-managed state, or wire both for external control (lets you sync the index with the URL or restore on refresh). - Clamped index. Out-of-range
activeIndexvalues clamp into bounds, so a stale URL parameter never crashes the dialog. - Loop or stop.
loop(defaulttrue) wraps from last to first; set it tofalseand the prev / next buttons disable at the ends. - Keyboard navigation. Left / Right move between images, Esc closes, Tab cycles inside the panel without escaping. The close button is focused on open so screen-reader users land on a clear exit.
- Focus-trap + restore. On open, focus moves into the close button on the next frame so motion has time to mount the panel. On close, focus restores to whatever element opened the lightbox.
- Single-image mode. When
images.length === 1, the prev / next buttons hide automatically — the dialog still works as a maximised image viewer. - Motion. Backdrop fades in; panel scale-pops from the top — both with
SPRINGS.snap.AnimatePresence initial={false}skips the first-render animation. - Scroll-lock. While open,
body.overflowis set tohiddenand restored on close so the page underneath can't scroll out from under the dialog.
Variants
Uncontrolled
<LightboxViewer images={images} defaultOpen={false} defaultActiveIndex={0} />No looping at the ends
<LightboxViewer
images={images}
open={open}
onOpenChange={setOpen}
activeIndex={index}
onActiveIndexChange={setIndex}
loop={false}
/>With captions
<LightboxViewer
images={[
{
id: "alpine",
src: "/alpine.jpg",
alt: "Alpine lake at dawn",
caption: "Alpine lake at dawn — long exposure, 30s.",
},
]}
open={open}
onOpenChange={setOpen}
/>Localised labels
<LightboxViewer
images={images}
open={open}
onOpenChange={setOpen}
label="Visor de imágenes"
prevLabel="Imagen anterior"
nextLabel="Imagen siguiente"
closeLabel="Cerrar visor"
/>Props
LightboxViewer
| Prop | Type | Default | Description |
|---|---|---|---|
images | readonly LightboxImage[] | — | Images to display. Order drives prev / next navigation. |
activeIndex | number | — | Controlled active index. Pair with onActiveIndexChange. |
defaultActiveIndex | number | 0 | Uncontrolled initial index. |
onActiveIndexChange | (index: number) => void | — | Fired when the active index changes. |
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fired when the lightbox opens or closes. |
loop | boolean | true | Wrap from last to first / first to last when navigating. |
label | string | "Image viewer" | Accessible name for the dialog (used when the image has no alt). |
prevLabel | string | "Previous image" | Accessible label for the Previous button. |
nextLabel | string | "Next image" | Accessible label for the Next button. |
closeLabel | string | "Close lightbox" | Accessible label for the Close button. |
className | string | — | Merged onto the dialog panel. |
overlayClassName | string | — | Merged onto the backdrop overlay. |
LightboxImage
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable identifier — used as the React key. |
src | string | — | Image URL. |
alt | string | — | Accessible name. Required for screen readers. |
caption | ReactNode | — | Optional visible caption rendered below the image. |
Accessibility
- The dialog panel carries
role="dialog"+aria-modal="true"+ anaria-label(defaults to the current image'salt, or tolabelwhen the image has no alt). A hidden labelled span backsaria-labelledbyso screen readers always announce the dialog name. - The image counter is wrapped in
aria-live="polite"so the active position is announced when it changes. - Keyboard: Left / Right navigate, Esc closes, Tab cycles focus inside the panel without escaping. On open, focus moves into the Close button on the next frame so motion has time to mount the panel. On close, focus restores to whatever element opened the lightbox.
- Body scroll is locked while the lightbox is open so the page underneath can't scroll out from under the dialog.
- Every control has an accessible name via
aria-label(prevLabel,nextLabel,closeLabel); pass localised strings for non-English locales.
Credits
- Extracted from:
terminal-dreams(src/components/frontend-design/sdp-image-gallery/ui/LightboxViewer.tsx). The original was a tightly-coupled child of the SDP Image Gallery Lab — it receivedindex,images: { index, aspectRatio }[], a project-specificfocusedElement/setFocusedElementpair lifted into the parent, and built image URLs frompicsum.photosseeds inside the component. craft-bits generalises it: a flatLightboxImage[]with propersrc/alt/ optionalcaption, controlled + uncontrolledopenandactiveIndex, looping toggle, localised labels, motion viaSPRINGS.snap, and focus management owned internally.