Build an image gallery with lightbox
By the end of this recipe you'll have a thumbnail grid that opens into a full-screen lightbox with keyboard arrows, swipe on touch, pinch zoom, a thumbnail strip at the bottom, and — critically — the page's scroll position restored exactly when the lightbox closes.
Overview
A grid that opens into a focus-trapped full-screen view. Arrow keys, swipe, and pinch — same component, three input modes.
Image galleries are easy to get 80% right and infuriating to get the last 20% wrong. The recipe gives you the last 20%: focus restored to the thumbnail that opened the lightbox, scroll position restored on the underlying page, keyboard arrows for left/right, Esc to close, swipe and pinch on touch, and a thumbnail strip so the user always knows where they are in the sequence. The lightbox is a focus-trapped <dialog> — modal semantics, no body-scroll-lock hack required.
Lightboxes should NEVER block scroll on the original page after close. Properly restore scroll position. Keyboard nav is non-negotiable. If your gallery isn't usable with arrows + Esc, it isn't shipped.
Skip this recipe if your images are documents, not photos. Document viewers want page-numbered navigation, find-in-text and a download button — different problem.
What you'll build
Three states: grid, lightbox with thumbnail strip, and a pinch-zoomed close-up.
Required pieces
A small surface — the heavy lifting is the focus + scroll restoration logic.
@corelithzw/react exports used here: Lightbox, useGallery, Button, Stack.
Step-by-step build
Render the grid as real <button>s, not divs
Each thumbnail is a button — it activates on Enter, Tab-stops correctly, and a screen reader announces it. Capturing the activating element here is what lets you return focus when the lightbox closes; refs would also work but the activeElement at close-time is simpler.
Open the lightbox as a real <dialog>
The native <dialog> element gives you focus trap and inert-the-rest-of-the-page for free when you call showModal(). Capture document.activeElement before opening; restore focus to it after closing. Scroll position is preserved automatically because the rest of the page is inert, not unmounted.
Wire keyboard, swipe, and pinch — three handlers, one index
Keyboard arrows are the canonical desktop nav. Touch swipe is the canonical mobile nav. Pinch is a transform on the current image, independent of navigation. All three end up calling setIndex or setZoom; the rest is local state.
Esc · close
+ / - / 0 · zoom keyboard
swipe / pinch · touch
Index is one source of truth — every input writes to it.
Switch to the Code tab to see the snippet.
Add the thumbnail strip — it doubles as the index marker
A horizontal strip of small thumbs anchored to the bottom: each is a real button, the current one has aria-current="true", and scrolling the strip keeps the current thumb visible with scrollIntoView({ inline: 'center' }). The user always knows where they are without doing maths.
Final composition
The whole gallery — grid, lightbox, keyboard / touch nav, thumbnail strip, and focus restoration.
Variations
Three forks. Each is a small diff from the final composition.
Download button
Add an explicit download per image. Don't auto-trigger; let the user pick.
<a
href={p.src}
download={p.alt + '.jpg'}
className="lb-download"
aria-label="Download image"
>
Download
</a>
Slideshow
Auto-advance every N seconds. Pause on hover and on focus, so keyboard users aren't fighting the timer.
useEffect(() => {
if (paused) return;
const id = setInterval(next, 4000);
return () => clearInterval(id);
}, [paused, index]);
<Button onClick={() => setPaused(p => !p)}>
{paused ? 'Play' : 'Pause'}
</Button>
Single-image expandable hero
One image on the page, click expands into the lightbox. Same component, photos length is one — strip and arrows hide themselves.
<Gallery photos={[hero]} title="Hero" />
// In LightboxView, skip the strip
// and arrows when photos.length === 1:
{photos.length > 1 && <ThumbStrip />}
{photos.length > 1 && <NavArrows />}
Accessibility
A lightbox is a modal dialog with custom navigation. Native <dialog> + careful focus management gets you most of the way.
- Thumbnails are real
<button>s. Each carries anaria-labellike "Open Shopfront 1, 1 of 8". Tab order through the grid is natural. - Focus is restored to the opener. The lightbox captures
document.activeElementon mount and re-focuses it on close — the keyboard user lands back exactly where they were. - Scroll position is preserved. The native
<dialog>+showModal()inerts the rest of the page without scroll-locking. No body-scroll-restore hack, no jump-to-top on close. - Keyboard navigation is non-negotiable. Left/right arrows move, Esc closes, +/-/0 control zoom. Every action has a keyboard path.
- The dialog announces what it is.
aria-label="Image 2 of 8: Shopfront detail"tells the SR user position and content on open. - Zoom level is announced. The "100%" indicator is a polite live region — pinch-zoom users hear "150%" as they pinch.
- Disabled arrows are real disabled buttons. At the first or last image, prev/next take
disabled— keyboard skips them, SR reads "previous, dimmed". - Thumb strip is a
role="listbox". Each thumb isrole="option"witharia-selected. SR users navigate it like a select.