Lightbox Gallery (React)
A React thumbnail wall that opens a full-screen lightbox: the image morphs in from its thumbnail as a shared element, with wheel/pinch zoom and drag panning, double-tap zoom, swipe and keyboard navigation, a counter and caption bar, a bottom thumbnail strip, and Esc or backdrop to close.
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
A thumbnail wall (CSS grid, gentle hover zoom, fixed 4:3 cells) — click any tile to open a full-screen lightbox: the large image scales up from the thumbnail's position as a shared element and shrinks back into place on close. Inside the lightbox the full gesture set is implemented with pointer events — mouse wheel or two-finger pinch zooms around the cursor / pinch midpoint, a single-finger drag pans once zoomed in (clamped to the image bounds), a horizontal swipe past 80px switches images at 1×, and a double-tap toggles between 1× and 2×. The toolbar carries the counter, zoom in/out, and close; arrow buttons and the ←/→ keys change images; a caption bar and a clickable thumbnail strip sit at the bottom. Opening locks page scroll, moves focus to the close button and cycles it inside the lightbox; closing returns focus to the original thumbnail.
npx shadcn@latest add "https://webberui.com/r/lightbox-gallery.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<LightboxGallery />
Installation
npx shadcn@latest add "https://webberui.com/r/lightbox-gallery.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/lightbox-gallery.
Usage
import { LightboxGallery, type LightboxImage } from "@/components/ui/lightbox-gallery";
const images: LightboxImage[] = [
{ id: "a", src: "/photos/a.jpg", thumb: "/photos/a-thumb.jpg", alt: "Sunrise at the shore", caption: "The east coast at 5 a.m." },
{ id: "b", src: "/photos/b.jpg", alt: "Sea of clouds in the valley" },
{ id: "c", src: "/photos/c.jpg", alt: "Old street in the afternoon", caption: "Light and shadow under the eaves" },
];
// Uncontrolled: click a thumbnail to open; the component manages open state and index
<LightboxGallery images={images} columns={3} loop />
// Controlled: open to a specific image from an external button
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
<LightboxGallery
images={images}
open={open}
onOpenChange={setOpen}
index={index}
onIndexChange={setIndex}
showThumbnails={false}
zoomMax={6}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
images | LightboxImage[] | — | Image list; id must be unique |
columns | number | 3 | Number of columns in the thumbnail wall |
gap | number | 8 | Gap between thumbnail cells (px) |
startIndex | number | 0 | Initial index in uncontrolled mode (the current image on first mount) |
index | number | — | Controlled current index; managed internally when omitted |
onIndexChange | (index: number) => void | — | Callback when the current index changes (thumbnail click, navigation, and swipe all fire it) |
open | boolean | — | Controlled open state of the lightbox; managed internally when omitted |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Callback when the open state changes (fires in both controlled and uncontrolled mode) |
loop | boolean | false | Whether navigation wraps around at both ends |
showThumbnails | boolean | true | Whether to show the thumbnail strip at the bottom of the lightbox |
showCounter | boolean | true | Whether to show the "3 / 12" counter |
zoomMax | number | 4 | Maximum zoom factor |
labels | Partial<LightboxGalleryLabels> | — | Custom UI strings (override only what you need) |
className | string | — | Forwarded to the outermost container |
LightboxImage
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique id; the shared-element animation pairs the thumbnail with the large image by it |
src | string | — | Full-size source (needs an intrinsic size: any raster image, or an SVG with width/height) |
alt | string | — | Alt text (required for accessibility); it is also announced by screen readers on navigation |
caption | string | — | Text for the caption bar at the bottom of the lightbox |
thumb | string | — | Thumbnail source; when omitted both the wall and the lightbox strip use src |
LightboxGalleryLabels
| Field | Type | Default | Description |
|---|---|---|---|
dialog | string | "圖片燈箱" (Image lightbox) | aria-label of the lightbox dialog |
open | string | "放大檢視" (View larger) | Suffix of each wall button's accessible name, forming "alt text (View larger)" |
close | string | "關閉" (Close) | Close button |
previous | string | "上一張" (Previous) | Previous button |
next | string | "下一張" (Next) | Next button |
zoomIn | string | "放大" (Zoom in) | Zoom-in button |
zoomOut | string | "縮小" (Zoom out) | Zoom-out button |
thumbnails | string | "縮圖列" (Thumbnails) | aria-label of the thumbnail strip at the bottom of the lightbox |
The LightboxImage, LightboxGalleryLabels, and LightboxGalleryProps types are also named exports.
How it works
- Shared-element entrance: the image the lightbox opens with shares a Motion
layoutIdwith its thumbnail, so it scales up from the thumbnail's position; if you close while still on that image it shrinks back into the thumbnail, and after navigating to another image it fades out instead. Images navigated to afterwards slide in and out horizontally, and zoom/pan reset on every switch. - Gestures: implemented with pointer events, so mouse, touch, and pen behave the same. The wheel (including trackpad pinch, which arrives as a wheel event with
ctrlKey) zooms around the cursor; a two-finger pinch tracks the distance and midpoint of two pointers, so you can pan while pinching; above 1× a single-finger drag pans, clamped to the image bounds; at 1× a horizontal displacement past 80px switches images (with resistance and a spring back at the ends whenloopis off); a second tap within 300ms and 24px counts as a double-tap and toggles between 1× and 2×; tapping the backdrop outside the image closes. - Keyboard: ←/→ to navigate, Home/End to jump to the ends, Esc to close,
+/-to zoom,0to reset to 1×. - Sizing: the large image is fitted inside the stage with
max-width: 100%; max-height: 100%, so images smaller than the stage are not upscaled;srcneeds an intrinsic size (all raster formats have one; give SVGs awidth/height). The previous and next images are preloaded while open. - Portal and scroll lock: the lightbox is rendered into
document.bodyviacreatePortalso ancestortransform/overflowcannot clip it; while open, an effect setsdocument.body.style.overflowtohiddenand restores the previous value on close or unmount.
Accessibility
- The lightbox is
role="dialog"witharia-modal="true"; itsaria-labelcomes fromlabels.dialog, and when a caption existsaria-describedbypoints at the caption bar. Focus moves to the close button on open, Tab/Shift+Tab cycle inside the lightbox, and focus returns to the clicked thumbnail on close - Every wall cell is a
button(aria-haspopup="dialog") whose accessible name is "alt text (View larger)"; toolbar, arrow, and strip buttons all carryaria-label, the current thumbnail is marked witharia-current, and anaria-live="polite"region announces "image n of N: alt text" on navigation - Every interaction has a keyboard equivalent (arrow keys to navigate, Esc to close,
+/-/0to zoom), and buttons show afocus-visibleoutline - When the user has "reduce motion" enabled at the system level, the shared-element morph becomes a fade, the horizontal slide becomes a fade, zoom snaps to the target without tweening, and the thumbnail strip scrolls instantly — nothing functional is lost