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.json

Usage

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

  1. Flat data, single dialog. A single images array drives the lightbox — each entry is id, src, alt, and optional caption. The component is one focus-trapped dialog, not a per-image overlay.
  2. Controlled + uncontrolled, twice. Both open and activeIndex accept the Radix value / defaultValue pair. Pass defaultOpen / defaultActiveIndex for self-managed state, or wire both for external control (lets you sync the index with the URL or restore on refresh).
  3. Clamped index. Out-of-range activeIndex values clamp into bounds, so a stale URL parameter never crashes the dialog.
  4. Loop or stop. loop (default true) wraps from last to first; set it to false and the prev / next buttons disable at the ends.
  5. 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.
  6. 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.
  7. Single-image mode. When images.length === 1, the prev / next buttons hide automatically — the dialog still works as a maximised image viewer.
  8. Motion. Backdrop fades in; panel scale-pops from the top — both with SPRINGS.snap. AnimatePresence initial={false} skips the first-render animation.
  9. Scroll-lock. While open, body.overflow is set to hidden and 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

PropTypeDefaultDescription
imagesreadonly LightboxImage[]Images to display. Order drives prev / next navigation.
activeIndexnumberControlled active index. Pair with onActiveIndexChange.
defaultActiveIndexnumber0Uncontrolled initial index.
onActiveIndexChange(index: number) => voidFired when the active index changes.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFired when the lightbox opens or closes.
loopbooleantrueWrap from last to first / first to last when navigating.
labelstring"Image viewer"Accessible name for the dialog (used when the image has no alt).
prevLabelstring"Previous image"Accessible label for the Previous button.
nextLabelstring"Next image"Accessible label for the Next button.
closeLabelstring"Close lightbox"Accessible label for the Close button.
classNamestringMerged onto the dialog panel.
overlayClassNamestringMerged onto the backdrop overlay.

LightboxImage

PropTypeDefaultDescription
idstringStable identifier — used as the React key.
srcstringImage URL.
altstringAccessible name. Required for screen readers.
captionReactNodeOptional visible caption rendered below the image.

Accessibility

  • The dialog panel carries role="dialog" + aria-modal="true" + an aria-label (defaults to the current image's alt, or to label when the image has no alt). A hidden labelled span backs aria-labelledby so 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 received index, images: { index, aspectRatio }[], a project-specific focusedElement / setFocusedElement pair lifted into the parent, and built image URLs from picsum.photos seeds inside the component. craft-bits generalises it: a flat LightboxImage[] with proper src / alt / optional caption, controlled + uncontrolled open and activeIndex, looping toggle, localised labels, motion via SPRINGS.snap, and focus management owned internally.