Home/ Cookbook/ Views/ Image gallery + lightbox

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.

Views 2 primitives · 1 block · 1 pattern ~40 min React · @corelithzw/react

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.

Shop fit-out · 12 photos
01 Grid — square thumbnails
02 Lightbox — arrows + strip
2.0× · pinch to zoom
03 Zoom — pinch on touch

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

01

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.

Step 1 · Grid@corelithzw/react
02

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.

2 / 8
Esc to close
Step 2 · Dialog open
03

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.

Code-only step

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.

Step 3 · Navigation
04

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.

Step 4 · Thumb strip

Final composition

The whole gallery — grid, lightbox, keyboard / touch nav, thumbnail strip, and focus restoration.

2 / 8100% · Close
Gallery.tsx@corelithzw/react

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 an aria-label like "Open Shopfront 1, 1 of 8". Tab order through the grid is natural.
  • Focus is restored to the opener. The lightbox captures document.activeElement on 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 is role="option" with aria-selected. SR users navigate it like a select.