Design: Image Gallery (Responsive, Lazy, Lightbox)
TL;DR
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 07_file_uploader_with_resume.md.)
- 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>.
What a senior is expected to say
- “Pre-generated variants on upload; client never resizes.
<picture>+srcset/sizesfor responsive + format negotiation. AVIF first, WebP fallback, JPEG final fallback.” - “CLS = 0 by reserving the box with
width/heightoraspect-ratioon every image.” - “Lazy load with native
loading=lazyfor the grid; preload neighbors in the lightbox so swipe-next is instant.” - “Virtualize above ~1000 items;
content-visibility: autois a cheap intermediate optimization for 100–1000.” - “Lightbox is a dialog: focus trap, focus return, body scroll lock, Escape,
inertthe background, and history entry for the back-button. I’d use Radix/Headless rather than hand-roll.” - “blurhash/thumbhash preview decodes instantly and prevents the blank-cell flash.”
Cross-references
- File uploader (the other half): 07_file_uploader_with_resume.md
- Core Web Vitals (LCP/CLS/INP): ../15_performance/
- Accessibility for modals: ../16_accessibility/
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/