ESC

Type to search the knowledge base.

Lightbox Image Gallery

Thumbnail grid that opens a dialog with prev/next, Escape, focus trap, and preload. Do not invent a carousel from scratch if a dialog plus index is enough.

intermediate7 min read
  • machine-coding
  • interview
  • a11y
  • react

People hear “gallery” and start cloning slides, looping transforms, and writing swipe math. The prompt is: click a thumbnail, see it large, go prev/next, close. That is an index in a dialog. A lightbox image gallery is a thumbnail grid plus the accessible modal you should already know, with two extra buttons and a preload of neighbors.

If you already have <dialog> and showModal(), you get focus trap, top layer, and Escape. Spend the remaining time on alt text, restore focus to the thumbnail that opened it, and not blocking the main thread with twelve full-size images.

Key takeaways

  • State is openIndex: number | null. Null is closed.
  • Native <dialog>.showModal() or the APG dialog pattern. Not a centered div.
  • Prev/next wrap or clamp. Pick one, keep buttons disabled if you clamp.
  • Preload index ± 1 when open. Do not fetch the whole album on mount.
  • Return focus to the thumbnail. Arrow keys inside the dialog are optional extras, not a second carousel library.

Index in a dialog, not a second page

The grid is a list of buttons (or links if each image has a URL you must share). Each button opens the same dialog at that index. The large image is images[openIndex]. Next is openIndex + 1. There is no route, no cloned slide, no translateX(-${i * 100}%).

type GalleryImage = {
  id: string;
  thumb: string;
  src: string;
  alt: string;
};

function wrap(i: number, n: number) {
  return (i + n) % n;
}

Wrapping is nicer for a lightbox. Clamping is nicer if they want Next disabled on the last photo. Either is fine. Disabled Next on a wrapping gallery is a product bug.

Preload:

function preload(src: string) {
  const img = new Image();
  img.src = src;
}

function preloadNeighbors(images: GalleryImage[], i: number) {
  const n = images.length;
  if (n === 0) return;
  preload(images[wrap(i + 1, n)].src);
  preload(images[wrap(i - 1, n)].src);
}

Call that when openIndex changes. The browser cache does the rest. Do not decode() 40 images up front.

Problem statement

Build a thumbnail grid. Activating a thumbnail opens a dialog with the large image, previous/next controls, and close. Escape and a close button dismiss. Focus is trapped while open and restored on close. Neighbor images preload.

Requirements

Must have

  • Thumbnail grid from an images array
  • Open dialog on activate, showing that image
  • Prev / next
  • Close: button, Escape (and optional backdrop)
  • Focus to the dialog on open, back to the thumbnail on close
  • alt on both thumb and large image (same meaningful string)

Should have

  • Native <dialog> with showModal()
  • Preload adjacent full-size sources
  • Counter text, “3 of 12”
  • Disable or wrap at ends, consistently

Nice to have

  • Keyboard arrows inside the dialog
  • Download / open original
  • Caption

Planning (5 minutes out loud)

  1. openIndex: number | null
  2. Reuse dialog behavior: trap, Escape, restore focus
  3. MVP: grid + dialog + prev/next + close
  4. Then preload and counter
  5. Do not build infinite carousel clones

If they already asked you for a carousel in another round, say how this is different: one image, no autoplay, no peek of the next slide.

Architecture

LightboxGallery
├── ThumbGrid
│   └── ThumbButton × n
└── Dialog
    ├── Close
    ├── Img
    ├── Prev / Next
    └── Status (n of m)

API

type LightboxGalleryProps = {
  images: GalleryImage[];
  wrap?: boolean;
};

Implementation sketch

function LightboxGallery({ images, wrap: shouldWrap = true }: LightboxGalleryProps) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  const dialogRef = useRef<HTMLDialogElement>(null);
  const lastThumb = useRef<HTMLElement | null>(null);

  const n = images.length;
  const current = openIndex === null ? null : images[openIndex];

  useEffect(() => {
    const el = dialogRef.current;
    if (!el) return;
    if (openIndex === null) {
      if (el.open) el.close();
      lastThumb.current?.focus();
      return;
    }
    if (!el.open) el.showModal();
    preloadNeighbors(images, openIndex);
  }, [openIndex, images]);

  function openAt(i: number, from: HTMLElement) {
    lastThumb.current = from;
    setOpenIndex(i);
  }

  function shift(delta: number) {
    if (openIndex === null) return;
    if (shouldWrap) {
      setOpenIndex(wrap(openIndex + delta, n));
      return;
    }
    setOpenIndex(Math.min(n - 1, Math.max(0, openIndex + delta)));
  }

  return (
    <>
      <ul>
        {images.map((img, i) => (
          <li key={img.id}>
            <button
              type="button"
              onClick={(e) => openAt(i, e.currentTarget)}
            >
              <img src={img.thumb} alt={img.alt} />
            </button>
          </li>
        ))}
      </ul>

      <dialog
        ref={dialogRef}
        aria-labelledby="lb-title"
        onClose={() => setOpenIndex(null)}
        onCancel={(e) => {
          e.preventDefault();
          setOpenIndex(null);
        }}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight") shift(1);
          if (e.key === "ArrowLeft") shift(-1);
        }}
      >
        <h2 id="lb-title" className="sr-only">
          Image viewer
        </h2>
        <button type="button" onClick={() => setOpenIndex(null)}>
          Close
        </button>
        {current && (
          <img src={current.src} alt={current.alt} />
        )}
        <button
          type="button"
          onClick={() => shift(-1)}
          disabled={!shouldWrap && openIndex === 0}
        >
          Previous
        </button>
        <button
          type="button"
          onClick={() => shift(1)}
          disabled={!shouldWrap && openIndex === n - 1}
        >
          Next
        </button>
        {openIndex !== null && (
          <p aria-live="polite">
            {openIndex + 1} of {n}
          </p>
        )}
      </dialog>
    </>
  );
}

showModal() puts the dialog in the top layer and makes Escape fire cancel. You still need a Close button. onClose keeps React state in sync if the user hits Escape.

Focus trap: native modal dialog does this. If they ban <dialog>, you owe the full pattern: inert background, Tab cycle, initial focus on Close or the container. That is the other article. Do not half-build it.

Thumbs as <img> inside <button>: the button’s accessible name should come from the image alt. Empty alt plus a nameless button is a silent control. Decorative thumbs with alt="" need aria-label on the button, e.g. “Open photo, golden retriever on a porch.”

Accessibility essentials

Piece Approach
Dialog <dialog> + showModal(), labelled by a title
Thumbs Button (or link) with a name from alt
Large image Same meaningful alt, not “image 3”
Close / prev / next Named buttons
Focus Into dialog on open, back to thumb on close
Escape Native cancel, or your own listener
Counter Polite live region, not on every rAF

Backdrop click: dialog with ::backdrop and a click on the dialog element (not the inner panel) can close. Do not close when clicking the image.

Interview scoring

Signal What they want
Model One index, one dialog
Scope No carousel engine
A11y Trap, Escape, restore, names, alts
Preload Neighbors, not the album
Keys Optional arrows inside the dialog
Talk You point at the modal pattern instead of re-deriving it

They may ask “what if there are 2,000 images?” Virtualize the grid, still one dialog, still preload ±1. They may ask swipe. Pointer or a scroll snap on the large image is extra; do not start there.

Footguns

  1. Carousel clones for a lightbox
  2. div overlay with no trap and no Escape
  3. Focus left on the thumb under an open overlay, or lost on close
  4. alt="" on the large image
  5. Preloading every src in useEffect([])
  6. Next wrapping while the button looks disabled

Interview out-loud answer

I would keep an openIndex of number | null and one native modal dialog. Thumbnails are buttons whose names come from the image alt. Opening calls showModal(), closing restores focus to that thumbnail. Prev and next change the index, wrap or clamp on purpose, and preload only the adjacent full-size files. This is a dialog plus an index, not a carousel. If <dialog> is off the table I reuse the APG modal pattern: trap, Escape, inert background, labelled title.

Further reading