Design: Image Gallery (Responsive, Lazy, Lightbox)
A grid of images that loads efficiently across viewports and devices, opens to a full-screen lightbox with keyboard/swipe navigation, and survives image counts from 20 to 20,000. The senior topics are responsive image sources (srcset/sizes/<picture>), modern formats (AVIF/WebP), CLS prevention (reserved aspect ratios), layered loading (LQIP/blurhash), virtualization at scale, and focus management in the lightbox (a real a11y trap).
Requirements to clarify
- Count. 20-image portfolio (no virtualization) vs 20K-image media library (virtualization + on-demand fetch).
- Layout. Fixed-aspect grid, masonry (variable heights), justified rows?
- Device range. Mobile to 4K, what’s the LCP target?
- Lightbox features. Zoom/pan? Slide-to-next gesture? Sharing? Comments? Metadata sidebar?
- Upload flow. Within this screen, or separate? (See Design: File Uploader with Resume.)
- Permissions / privacy. Signed URLs per image, or public?
API contract
GET /api/galleries/:id/images?after=<cursor>&limit=50
→ {
"items": [
{
"id": "img_123",
"width": 4032,
"height": 3024,
"blurhash": "L9G[$y%MWB%g00fQayWB~qof%goe", // tiny preview placeholder
"variants": {
"thumb_avif": "https://cdn/.../thumb.avif",
"thumb_webp": "https://cdn/.../thumb.webp",
"medium_avif": "https://cdn/.../med.avif",
"full": "https://cdn/.../orig.jpg"
}
}
],
"nextCursor": "..."
}Variants are pre-generated server-side on upload (or on-demand via an image CDN like Cloudinary/imgix/Cloudflare Images). Don’t expect the client to resize.
Client data model
- TanStack
useInfiniteQueryfor the grid pages. selectedIdin URL state (?image=img_123) so a deep link opens the lightbox on the right image and browser back closes it.
Responsive image sources
The single most important pattern for a senior answer:
<picture>
<source
type="image/avif"
srcSet={`${img.thumb_avif} 1x, ${img.thumb_avif_2x} 2x`}
sizes="(max-width: 600px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
<source
type="image/webp"
srcSet={`${img.thumb_webp} 1x, ${img.thumb_webp_2x} 2x`}
sizes="..."
/>
<img
src={img.fallback_jpg}
width={img.width}
height={img.height}
alt={img.alt ?? ""}
loading="lazy"
decoding="async"
style={{ aspectRatio: `${img.width} / ${img.height}` }}
/>
</picture>Three things this gets right:
- Format negotiation via
<source type="image/avif">— browsers pick AVIF if supported, fall back through WebP to JPEG. srcset+sizes— browser picks the right resolution for this viewport and DPR. No serving 4K to a phone.width/height+aspect-ratio— reserves the layout box so CLS is zero. Settingaspect-ratioin CSS handles cases where the actual file size is different from the declared dimensions.
Lazy loading and blur-up
loading="lazy"on<img>— native, no JS. Browser starts the fetch when the image is near the viewport.decoding="async"— decode off the main thread.- LQIP / blurhash placeholder — a tiny (20-50 byte) hash that decodes to a blurred preview. Renders instantly, full image fades in.
<div style={{ aspectRatio: ratio, backgroundImage: blurDataUrl(img.blurhash) }}>
<img onLoad={() => setLoaded(true)} style={{ opacity: loaded ? 1 : 0, transition: "opacity .3s" }} />
</div>Use blurhash or thumbhash (newer, smaller); both work the same way.
When to virtualize
| Count | Strategy |
|---|---|
| < 100 | Render all, lazy-load <img> |
| 100 – 1000 | IntersectionObserver to load images as rows scroll in, plus content-visibility: auto on grid cells |
| > 1000 | Virtualize the grid with @tanstack/react-virtual |
content-visibility: auto is a cheap CSS-only win — the browser skips rendering for off-screen cells (with a contain-intrinsic-size hint to reserve space):
.gallery-cell {
content-visibility: auto;
contain-intrinsic-size: 300px 300px;
}Lightbox (modal) — the a11y trap
Opening the lightbox is where senior answers separate from junior ones. The full requirement:
- Focus moves into the lightbox on open (to the close button or the image).
- Focus is trapped inside the lightbox (Tab cycles within; Shift+Tab too).
- Escape closes the lightbox.
- Focus returns to the gallery item that opened it on close.
role="dialog"+aria-modal="true"+aria-labelledby(image caption).inerton the rest of the page (oraria-hidden="true") so screen readers don’t read background content.- Body scroll lock while open.
- Arrow keys navigate next/prev image; swipe gestures on touch.
- History entry — opening pushes a history state so browser back closes the lightbox (the deep-link contract).
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
previouslyFocused?.focus();
};
}, [open]);In practice use Radix Dialog or Headless UI Dialog — they implement all of the above. Hand-rolling is doable but the failure modes are subtle.
Preloading neighbors
When the lightbox is open on image N, prefetch images N+1 and N-1 so swiping forward/backward feels instant:
useEffect(() => {
if (!images[index + 1]) return;
const link = document.createElement("link");
link.rel = "preload"; link.as = "image";
link.href = images[index + 1].variants.full;
document.head.appendChild(link);
return () => link.remove();
}, [index]);Failure modes
- CORS / 403 on signed URLs that expired — re-sign and retry once; show a “refresh” button on persistent failure.
- Image fails to load — fallback poster + retry button; don’t break the layout (the reserved aspect ratio holds the slot).
- Massive file triggers OOM on mobile — server should refuse to deliver originals; serve a “large” variant capped at e.g. 2048px.
- Slow connection — render placeholders + show actual progress with the
Image.decode()API on each image, or fall back to a spinner.
Performance budget
| Metric | Target |
|---|---|
| LCP (first visible image fully rendered) | < 2.5s |
| CLS | 0 (zero — reserved aspect ratios) |
| INP for opening lightbox | < 200ms |
| Total transferred per visit | < 5MB for a 50-image grid |
The CLS = 0 is the cheap senior-level win — set width/height or aspect-ratio on every <img>.
Related
Further reading
- web.dev — Choose the right image format: https://web.dev/articles/choose-the-right-image-format
- MDN —
<picture>,srcset,sizes: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture - BlurHash: https://blurha.sh/ · ThumbHash: https://evanw.github.io/thumbhash/
- WAI-ARIA Dialog pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
Interview angle 6
- “Where do image variants come from?” - generated on upload, server-side.
The client never resizes: it picks from what exists via
<picture>withsrcsetandsizes, with AVIF first, WebP next and JPEG as the final fallback so every browser gets something. - “How do you get CLS to zero?” - reserve the box before the bytes arrive,
with
widthandheightattributes or anaspect-ratio. In a grid this is the whole game, because every unsized image shifts everything below it. - “What is the loading strategy?” - native
loading="lazy"for the grid, and in the lightbox preload the neighbouring images so swiping to the next one is instant. Those are opposite strategies for the same assets, chosen by what the user is about to do. - “How do you handle very large galleries?” - virtualize past about a
thousand items. Between a hundred and a thousand,
content-visibility: autowithcontain-intrinsic-sizeis a much cheaper intermediate step that skips offscreen rendering without restructuring the component. - “What makes the lightbox correct?” - it is a dialog, so: focus trap, focus
returned to the trigger on close, body scroll locked, Escape closes,
background
inert, and a history entry so the back button does what the user expects on mobile. Use a headless library rather than assembling that by hand. - “How do you avoid the blank-cell flash?” - a blurhash or thumbhash placeholder, which is a handful of bytes in the payload and decodes instantly, so a cell shows a colour-accurate blur rather than nothing.