frontend / frontend system design / 03_image_gallery.md

Design: Image Gallery (Responsive, Lazy, Lightbox)

5 min read source

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 useInfiniteQuery for the grid pages.
  • selectedId in 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:

  1. Format negotiation via <source type="image/avif"> — browsers pick AVIF if supported, fall back through WebP to JPEG.
  2. srcset + sizes — browser picks the right resolution for this viewport and DPR. No serving 4K to a phone.
  3. width/height + aspect-ratio — reserves the layout box so CLS is zero. Setting aspect-ratio in 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;
}

Opening the lightbox is where senior answers separate from junior ones. The full requirement:

  1. Focus moves into the lightbox on open (to the close button or the image).
  2. Focus is trapped inside the lightbox (Tab cycles within; Shift+Tab too).
  3. Escape closes the lightbox.
  4. Focus returns to the gallery item that opened it on close.
  5. role="dialog" + aria-modal="true" + aria-labelledby (image caption).
  6. inert on the rest of the page (or aria-hidden="true") so screen readers don’t read background content.
  7. Body scroll lock while open.
  8. Arrow keys navigate next/prev image; swipe gestures on touch.
  9. 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/sizes for responsive + format negotiation. AVIF first, WebP fallback, JPEG final fallback.”
  • “CLS = 0 by reserving the box with width/height or aspect-ratio on every image.”
  • “Lazy load with native loading=lazy for the grid; preload neighbors in the lightbox so swipe-next is instant.”
  • “Virtualize above ~1000 items; content-visibility: auto is a cheap intermediate optimization for 100–1000.”
  • “Lightbox is a dialog: focus trap, focus return, body scroll lock, Escape, inert the 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

Further reading