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.
- 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 centereddiv.- Prev/next wrap or clamp. Pick one, keep buttons disabled if you clamp.
- Preload
index ± 1when 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
imagesarray - 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
alton both thumb and large image (same meaningful string)
Should have
- Native
<dialog>withshowModal() - 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)
openIndex: number | null- Reuse dialog behavior: trap, Escape, restore focus
- MVP: grid + dialog + prev/next + close
- Then preload and counter
- 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
- Carousel clones for a lightbox
divoverlay with no trap and no Escape- Focus left on the thumb under an open overlay, or lost on close
alt=""on the large image- Preloading every
srcinuseEffect([]) - Next wrapping while the button looks disabled
Interview out-loud answer
I would keep an
openIndexofnumber | nulland one native modal dialog. Thumbnails are buttons whose names come from the image alt. Opening callsshowModal(), 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.
Related on this site
- Accessible Modal Dialog
- Image Carousel
- Accessible Modals Patterns
- Focus Management
- The dialog element
- React portals