Design an image lightbox with zoom
The full-screen photo viewer that opens when you tap a thumbnail in Google Photos, an Airbnb listing or an Amazon product page: grow the photo out of its thumbnail, load a sharp version without a blank flash, let people zoom to the pixel and pan, swipe to the next photo or down to close, and keep Back, focus and memory under control.
The question
We have a grid of photos on a listing page. When someone taps a photo, it should open full screen, sharp, and zoomable to full detail. They can move to the next and previous photo, and close it again.
Design this lightbox as a reusable component. Walk me through how it opens, how you load images, how zoom and gestures work on touch, mouse and keyboard, how it behaves with the Back button and links, and what the API looks like.
Real-life examples: Google Photos full-screen viewer, Airbnb listing photo tour, Amazon product image zoom, Unsplash photo modal, The X (Twitter) media viewer, Libraries like PhotoSwipe, yet-another-react-lightbox and Fancybox
Requirements: one photo, the whole screen, every input
A lightbox is a viewer that covers the page with a dark backdrop and shows one image as large as the screen allows. The name comes from the glowing table photographers used to look at slides. You know it from Google Photos, from the photo tour on an Airbnb listing, from the zoomable product shots on Amazon, and from libraries like PhotoSwipe, yet-another-react-lightbox and Fancybox.
It sits between several neighbours, and an interviewer will expect you to know where the borders are. The image-carousel guide slides between images inside the page. The modal-dialog guide owns focus traps, the top layer and closing rules. The image-cropper guide owns the zoom and pan math with clamping, and the stories-viewer guide owns timed, full-screen media with tap zones.
A lightbox borrows from all four, so this guide points to those guides for the shared parts. It spends its words on what is special here: the opening animation, loading a sharp image at the right moment, zoom that plays well with swiping, and Back that closes the viewer instead of leaving the page.
Questions to ask
| Question | Answer we assume | What it changes |
|---|---|---|
| Where do people open it from? | A grid of thumbnails on a listing or album page, 24 to 200 photos. | We know the thumbnail's position and already have a small image loaded. |
| How big are the originals? | Phone photos, 4032 by 3024 pixels, 2 to 6 MB as JPEG. A few panoramas and floor plans up to 12,000 pixels wide. | We never load the original just to look. Very large images may need tiles. |
| How deep can people zoom? | Up to 1 image pixel per screen pixel, so they can read a label on a product. | We need a second, larger image that loads only on zoom. |
| Which devices? | 60 percent phones, the rest laptops with trackpads and desktops with mice. Keyboard only must work. | Touch gestures, wheel and pinch, and a full set of buttons and keys. |
| Should a link open a specific photo? | Yes. Shared links and reloads land on that photo. | The open photo lives in the URL. |
| Captions and details? | A caption per photo and an optional info panel with camera data. | Text has to stay readable on top of any photo. |
| Is it a component for many teams? | Yes: listings, reviews and the help center use it. | A small, typed API with slots for custom parts. |
Functional requirements
- Open from a thumbnail with an animation that grows the photo from where it was, and close back into it.
- Show the photo fitted to the screen right away, then swap to a sharp version without a blank frame.
- Zoom by double tap or double click at the point tapped, by pinch, by trackpad pinch or Ctrl and wheel, and by plus and minus buttons. Pan when zoomed.
- Go to the next and previous photo by swipe, by arrow keys and by on-screen buttons. Show a counter like 3 of 24.
- Close by Escape, by a close button, by swiping down, and by the browser Back button.
- Show a caption and alt text for every photo, and an optional info panel.
- Put the open photo in the URL so it can be shared and survives a reload.
Non-functional requirements
- Opening feels instant: the animation starts within 100 ms of the tap, using the thumbnail that is already decoded.
- The sharp screen-size image shows within about 1 second on a 4G phone, and the next photo is ready before the swipe ends.
- Gestures run at 60 frames per second: only
transformandopacitychange while a finger is down. - Memory stays bounded: a phone should not hold more than about 3 to 5 large decoded images at once, whatever the album size.
- No layout shift on the page behind when it opens and closes.
- Fully usable with a keyboard and a screen reader, and with one finger or one mouse button only.
Out of scope
- Editing, cropping and filters: that is the image-cropper and photo editor territory.
- Video and live photos. The same shell can host them later through a custom slide renderer.
- Making the thumbnails and the grid: the pinterest guide covers a masonry grid. We get a list of items and a start index.
- Uploading and server side resizing. We assume an image service that returns any width on request.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes a lightbox hard
A first version takes an afternoon: a fixed div with a black background, an img with object-fit: contain, two arrow buttons and a close button. It works on a laptop with fast Wi-Fi. Then real use finds five problems, and each one drives a later chapter.
- One photo, three sizes. The grid shows a 300 pixel thumbnail. The screen needs about 1,200 to 2,400 pixels. Deep zoom needs the original, 4,000 pixels or more. Each is roughly 5 to 20 times more bytes than the one before, so you must pick the right one at the right moment, and swap between them without a flash.
- The jump from small to big. The thumbnail sits somewhere in the grid. The viewer covers the screen. If the photo just appears, the eye loses its place. A good lightbox animates the photo from its spot in the grid to the center, and back on close.
- Fingers mean three things. A horizontal drag can mean pan the zoomed photo or go to the next one. A vertical drag can mean pan or close. Two fingers mean zoom. The viewer has to decide which one the person meant within the first few pixels of movement, and never guess wrong twice.
- It is a page inside a page. It has to own focus, stop the page behind from scrolling, close on Back, and have its own URL, while the page behind keeps its scroll position and state.
- Big images are heavy in memory. A 4032 by 3024 photo is 3 MB on the wire but about 49 MB once decoded (width times height times 4 bytes). Preloading ten of those on a phone is how tabs crash.
The rest of the guide takes these in the order a person meets them: opening, loading, zoom and pan, gestures, navigation and the URL. Then we put the pieces into an architecture, a data model and an API, and finish with accessibility, performance and the interview itself.
Think first
Why not just put the original image in an img with object-fit: contain and let the browser scale it?
It works visually, but it is the slowest and heaviest choice. A 4 MB original takes several seconds on a phone network, and while it loads the person sees nothing.
It also decodes the full 49 MB bitmap even when the screen shows it at a quarter of the size. Do this for the current and two neighbour photos and you hold about 150 MB for no visible gain.
And object-fit gives you no zoom or pan. You still need a transform layer on top, so the simple version does not stay simple.
Opening: from thumbnail to full screen
The first thing a person sees is the open animation, and it sets their sense of quality for the rest. The goal: the photo they tapped grows out of its spot in the grid to fill the screen, the backdrop fades in behind it, and the whole thing takes about 250 to 300 ms. On close, it shrinks back into the same spot, so the eye always knows where it is.
The naive version mounts the viewer and fades it in. Nothing is broken, but the person loses context: which photo did I open, and where will I be when I close it? With a grid of 60 similar room photos on a listing, that matters. The shrink-back on close is even more useful than the grow on open, because it shows where in the grid you are now after swiping through ten photos.
Decision How do we animate the photo from the grid into the viewer?
What we pick: We use FLIP with the Web Animations API as the base. The deciding reason is the close gesture: swipe down to close has to start from the photo's live position under the finger, and only an animation we own can do that. View Transitions are a fine choice for a simple viewer without gestures, and worth a try if your team already uses them for route changes.
transform changes during the animation. With transform-origin: 0 0, the invert step is one scale (thumbnail width over photo width) plus the difference of the top left corners.FLIP in 20 lines
The key to simple math is transform-origin: 0 0 on the big photo. Then the transform that places it on the thumbnail is a plain scale plus the difference of the top left corners. We know the big photo's final box before it loads, because every item carries its width and height.
function playOpen(thumb: Element, photo: HTMLElement, reduceMotion: boolean) {
const first = thumb.getBoundingClientRect();
const last = photo.getBoundingClientRect(); // viewer already rendered
const scale = first.width / last.width;
const dx = first.left - last.left;
const dy = first.top - last.top;
if (reduceMotion) {
return photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
}
return photo.animate(
[
{ transform: `translate(${dx}px, ${dy}px) scale(${scale})` },
{ transform: "none" },
],
{ duration: 280, easing: "cubic-bezier(0.2, 0, 0, 1)" },
);
}
Grids often crop thumbnails to squares with object-fit: cover, while the photo is 3:2. A plain scale would then show the full 3:2 photo growing out of a square, which looks like a jump. The fix is to animate a clip too: start the big photo with clip-path: inset(...) that hides the parts the thumbnail cropped away, and animate the inset to zero during the same 280 ms. PhotoSwipe has an option for cropped thumbnails that does the same.
Close is the same animation in reverse, with one check first: is the thumbnail still on screen? After swiping from photo 3 to photo 17, the grid behind may need to scroll so thumbnail 17 is visible. Scroll it into view without animation while the viewer still covers the page, then measure it and play. If the thumbnail is gone (filtered out, or a virtualized row not rendered), fall back to a fade.
The thumbnail is the placeholder
The thumbnail is already downloaded and decoded, which makes it the perfect placeholder. Render the viewer's img with the thumbnail URL first, sized to the final box, and start the animation right away. Scaled up several times it looks soft, but the colors and shapes are right, so the eye reads it as the photo. When the sharp image is decoded, swap it in: the person sees the photo sharpen, not a spinner.
Two details make this work. First, the thumbnail URL must be exactly the one the grid used, so it comes from the memory cache with no request. Second, the thumbnail should keep the photo's aspect ratio. If the grid only has square crops, fill the parts the square does not cover with a stored dominant color or a BlurHash (a 20 to 30 character string that decodes to a tiny blurred preview).
Check your understanding · Exercise 2
You implement the open animation with FLIP. Which statements are true? Select all that apply.
Progressive loading: the right pixels at the right moment
Every photo in the lightbox exists in three useful sizes. The thumbnail is already on screen in the grid. The screen image is big enough to look sharp when fitted to this screen, and the zoom image is big enough to look sharp at the deepest zoom we allow. The design question is which one to load, when, and how to swap without a blank frame.
The image-optimization lesson covers formats and image CDNs in general. Here we apply them to a viewer whose display size changes with every pinch.
Assume an image service that resizes on request, like /img/abc123?w=1920&fmt=avif, and a fixed ladder of widths so CDN caches stay warm: 400, 1280, 1920, 2560 and 4096. Here is what one 4032 by 3024 phone photo costs at each rung. Bytes are rough AVIF or WebP sizes for a typical photo; decoded memory is width times height times 4 bytes.
| Rung | Pixels | Bytes on the wire | Decoded memory | Used for |
|---|---|---|---|---|
| 400 | 400 by 300 | about 20 KB | 0.5 MB | Grid thumbnail and open placeholder |
| 1280 | 1280 by 960 | about 120 KB | 4.9 MB | Phone screens, fitted |
| 1920 | 1920 by 1440 | about 250 KB | 11 MB | Desktop at 1x, small laptops at 2x |
| 2560 | 2560 by 1920 | about 420 KB | 20 MB | Laptops at 2x, fitted |
| 4096 | 4032 by 3024 (capped) | about 1.2 MB | 49 MB | Zoomed past the screen image only |
Decision How does the viewer choose which image to load?
What we pick: We compute the width in JavaScript for the screen image and the zoom image, and keep a srcset on the server-rendered markup for the first paint. Tiles are a separate renderer that we plug in only for items marked as huge, like floor plans.
const RUNGS = [400, 1280, 1920, 2560, 4096];
function pickWidth(item: Item, box: { w: number; h: number }, zoom: number) {
const fit = Math.min(box.w / item.width, box.h / item.height, 1);
const cssWidth = item.width * fit * zoom;
const dpr = Math.min(window.devicePixelRatio, 2);
const needed = Math.min(cssWidth * dpr, item.width);
return RUNGS.find((r) => r >= needed) ?? RUNGS[RUNGS.length - 1];
}
Worked examples with that function: an iPhone at 393 by 852 CSS pixels and DPR 3 shows a landscape 4:3 photo 393 CSS pixels wide, needs 786 device pixels with DPR capped at 2, and loads the 1280 rung. A MacBook Air with a 1440 by 820 viewer box at DPR 2 shows the same photo 1093 CSS pixels wide (height limited), needs 2187, and loads 2560. A 1080p desktop with a 1920 by 1000 viewer box at DPR 1 needs 1333 and loads 1920.
The cap is not just a guess. Twitter capped timeline images at 2x on 3x phones in 2019 and reported about a third less data with no visible change, partly because many OLED screens sold as 3x have that resolution only in green, not in red and blue. The zoom image still lets people see every pixel when they ask for it.
Load the zoom image only past the screen image
When the person zooms, run pickWidth again with the new zoom. While the answer is still the rung we have, do nothing: the screen image still has at least one image pixel per device pixel, so it looks sharp. Only when the answer jumps a rung do we fetch, and only after the gesture ends, so a pinch that goes in and back out never downloads 1.2 MB. Most people never zoom, so most people never pay for the zoom image.
Decode before swapping
If you set img.src to the new URL directly, the browser may show a blank or half drawn frame while it downloads and decodes. Our rough estimate, not a published figure: decoding costs about 5 to 15 ms per megapixel on a mid-range phone, so a 12 MP JPEG takes 60 to 180 ms. Instead, load the new URL in a detached Image, wait for decode(), and only then put that element in place of the old one. Until then, the visible photo keeps its old pixels.
async function swapWhenReady(el: HTMLImageElement, url: string, isCurrent: () => boolean) {
const next = new Image();
next.className = el.className;
next.alt = el.alt;
next.src = url;
try {
await next.decode(); // rejects if the download or decode fails
} catch {
return "failed";
}
if (!isCurrent()) return "stale"; // user swiped away meanwhile
el.replaceWith(next); // the decoded element itself goes in, so the next paint is sharp
return "swapped";
}
When tiled deep zoom is worth it
Tiles change the cost model: you never decode the whole image, only the tiles of about 256 pixels that are visible at the current level. A 12,000 by 8,000 floor plan has about 15 levels, and a 1080p screen shows about 40 to 60 tiles at a time, roughly 10 to 16 MB decoded instead of 384 MB.
The formats are standard. Deep Zoom Image (DZI) is an XML descriptor plus a folder of tiles per level. The IIIF Image API puts region, size, rotation, quality and format in the URL, for example /iiif/plan42/0,0,2048,2048/256,/0/default.jpg. OpenSeadragon, an open source deep zoom viewer, reads both.
Our rule: if the zoom image, capped at 4096 on the long side, shows enough detail, use the three-size model. That covers phone photos and product shots; PhotoSwipe's own docs recommend images no larger than about 3000 by 3000 and offer tiling only as an experimental plugin.
If people need to read small text on a scanned plan, inspect brush strokes on a painting, or pan a map, mark the item as kind: "tiled" and render it with a tile viewer inside the same lightbox shell. Gigapixel art viewers like Google Arts and Culture, and museum sites built on IIIF, are the classic users.
Decode times are our rough estimate (about 5 to 15 ms per megapixel), not measurements; bytes follow the ladder in this guide.
Check your understanding · Exercise 3
A 4:3 photo opens in the lightbox on a laptop with a 1440 by 820 CSS pixel viewer box at DPR 2. Which statements are true? Select all that apply.
Zoom and pan: three levels, one transform
The image-cropper guide derives the zoom and pan math in detail: zooming around a point, clamping, and pinch with two pointers. We reuse that math and focus here on the choices that are different in a viewer. A cropper must always cover its frame. A lightbox must always keep the photo on screen, centers it when it is small, and must share every drag with the swipe to the next photo.
Fit, detail and max
We describe zoom as a scale s from image pixels to CSS pixels, and name three levels. Fit is the scale that shows the whole photo: min(boxW / imgW, boxH / imgH), never above 1 so small images are not blown up. Detail is one image pixel per device pixel: 1 / devicePixelRatio. Max is twice detail, so people can look closely at a label, but not so far that every pixel becomes a blurry square.
If detail is less than twice fit (a small image or a large screen), we use 2 * fit as the double tap target so the zoom is always noticeable. PhotoSwipe's defaults take the other route and work from fit alone: double tap goes to 2.5 times fit (but no wider than 3000 pixels) and max is 4 times fit. That is simpler, but as the table shows, the same multiple of fit means very different detail on different screens.
| Device | Fit for a 4032 by 3024 photo | Detail | Double tap goes to |
|---|---|---|---|
| Phone, 393 CSS px wide, DPR 3 | 0.097 | 0.333 | 0.333, about 3.4 times fit |
| Laptop, 1440 by 820 box, DPR 2 | 0.271 | 0.5 | 0.542, twice fit |
| Desktop, 1920 by 1000 box, DPR 1 | 0.331 | 1.0 | 1.0, about 3 times fit |
Zoom at the point under the finger
Double tap, pinch and Ctrl and wheel all zoom around a point: the spot under the finger or cursor must stay under it. With the photo drawn as translate(tx, ty) scale(s) and transform-origin: 0 0, the rule is one line per axis. Every zoom input calls this one function, so they all behave the same.
function zoomAt(v: View, px: number, py: number, nextScale: number): View {
const s = clamp(nextScale, v.fit * 0.8, v.max); // a little rubber band below fit
const k = s / v.scale;
return clampPan({
...v,
scale: s,
tx: px - (px - v.tx) * k,
ty: py - (py - v.ty) * k,
});
}
Pan bounds
When the scaled photo is wider than the viewer, the left edge may not move right of the viewer's left edge and the right edge may not move left of the viewer's right edge: tx stays between boxW - imgW * s and 0. When the scaled photo is narrower than the viewer, it is centered on that axis: tx = (boxW - imgW * s) / 2. Same for y. Each axis is checked on its own, so a wide panorama can pan sideways while staying centered vertically.
During a drag, going past a bound is allowed with resistance: the photo moves a third of the finger's distance, which tells the person they hit an edge. On release, it springs back. The amount of overshoot at the edge is also the signal the gesture layer uses to hand the drag over to a swipe, as the next chapter shows.
tx stays between boxW - imgW * s and 0. Smaller: tx is fixed at half the leftover space.Inertia
A pan that stops dead when the finger lifts feels broken on a phone. Keep the last 100 ms of pointer samples, compute the velocity at release in pixels per ms, and keep moving each animation frame with friction: multiply the velocity by about 0.95 per 16 ms frame and stop below 0.02 pixels per ms. Clamp to the bounds every frame, so a fling ends softly at the edge. Skip inertia for mouse drags, where people expect the photo to stop with the cursor.
Keep it smooth, then make it sharp
Write the transform in one requestAnimationFrame per frame, never per pointer event. Add will-change: transform only while a gesture is active. Chrome keeps a will-change layer rasterized at the scale it started with, so a photo zoomed while promoted can stay soft. Removing will-change when the gesture ends lets the browser draw it sharp again at the new scale, and frees the layer's memory.
Check your understanding · Exercise 4
A person double taps the left edge of a fitted photo on a phone. What should happen? Select all that apply.
Gestures: deciding what a finger means
In a full-screen viewer, one finger can mean four things: pan the zoomed photo, swipe to the next photo, swipe down to close, or tap. Two fingers mean pinch. The stories-viewer guide handles taps and holds on full-screen media; the image-carousel guide handles the sliding strip. The part that is special to a lightbox is the arbitration between pan, swipe and pinch, because the same horizontal drag must pan when zoomed and navigate when not.
The naive approach attaches separate handlers: a swipe library on the strip and a pan handler on the photo. Both see the same pointermove. Zoom in, drag to look at the right side of the photo, and the strip slides to the next photo at the same time. Every viewer built this way gets the bug report "I can't pan, it keeps changing photos".
Decision Who decides what a drag means?
What we pick: One gesture controller. The decision step is the whole point: it reads the zoom state, the direction of the first movement and the number of pointers, and picks exactly one gesture. It is also what makes the handover from pan to swipe at the edge possible; PhotoSwipe has the same behavior on by default for touch (its allowPanToNext option).
The decision rules
- A second pointer lands before the decision: pinch. If it lands after a swipe started, ignore it.
- Wait until the finger moves 10 CSS pixels from where it started. Before that, it may still be a tap.
- If the photo is zoomed past fit: pan. If the pan is already pinned against the left or right bound and the drag pushes further in that direction, switch to swipe for the rest of this drag.
- If the photo is at fit and the movement is mostly horizontal (the x distance is more than the y distance): swipe to navigate.
- If the photo is at fit and the movement is mostly downward: dismiss. Upward does nothing, or opens the info panel if the product has one.
- No movement past 10 pixels and the pointer lifts: tap. Wait 250 ms for a second tap. Two taps: zoom toggle at that point. One tap: show or hide the controls.
The 250 ms wait is the price of double tap: a single tap to hide the controls feels a little slow. Most viewers accept it. On mouse devices you can skip the wait, because double click and click are separate events and a single click on the photo does nothing.
Ending a swipe or a dismiss
A swipe moves the whole strip with the finger. On release, go to the next photo if the drag passed 25 percent of the screen width or the release velocity is above 0.3 pixels per ms (a quick flick), otherwise snap back.
A dismiss drags the photo down, shrinks it slightly and fades the backdrop: backdrop opacity is 1 - dy / (height / 2). Release past 120 pixels or with a fast downward flick plays the close animation, starting from where the photo is now and flying back into its thumbnail. That is the FLIP case from the opening chapter, which is hard to do with a browser-run view transition.
While dragging: strip moves 60 px. On release: snap back (needs 98 px or a flick).
Keeping the page behind still
A modal dialog blocks clicks on the page behind it, but not scrolling: a wheel or trackpad scroll can still move the page under the backdrop, and the page is where the close animation needs to land. The modal-dialog guide covers scroll locking in full; for a lightbox, three parts matter.
- Set
overflow: hiddenon thehtmlelement while open. On systems with classic scrollbars (most Windows setups) the scrollbar disappears, the page gets about 15 pixels wider and centered content shifts sideways, a visible layout shift behind the fading backdrop. - Prevent the jump with
scrollbar-gutter: stableonhtml, which keeps the scrollbar's space reserved. It is supported in Chrome, Edge and Firefox, and in Safari since 18.2. The older fallback is measuringwindow.innerWidth - document.documentElement.clientWidthand adding it as right padding. - Inside the viewer,
touch-action: nonestops the browser from scrolling or zooming the page on touch, andoverscroll-behavior: containon any scrollable part, like the info panel, stops scrolls from chaining to the page.
Check your understanding · Exercise 5
The photo is zoomed in and pinned against its right edge. The person drags left with one finger. What should the gesture controller do? Select all that apply.
Architecture, data model and API
We now have all the behaviors. The architecture question is where each one lives so that 60 frames per second gestures, async image loading, browser history and a React tree don't step on each other. The guiding rule: anything that changes every frame stays out of React state, and anything that is pure math stays out of the DOM.
| Part | Owns | Why it is separate |
|---|---|---|
| Lightbox shell | The modal dialog, top layer, focus trap and return, scroll lock, backdrop | Same for every gallery. Reuses the modal-dialog design. |
| Viewer store | Open or closed, current index, controls visible, zoom level after each gesture | The single source of truth that React renders from. Changes a few times per second at most. |
| Gesture controller | Pointer, wheel and key input, the decision step, velocities | One place for the arbitration rules. Emits intents like pan, zoomAt, next, dismiss. |
| Zoom and pan model | Fit, detail and max levels, zoomAt, clampPan, inertia steps | Pure functions: unit tested without a browser. |
| Slide strip | Three recycled slides, the strip offset, writing transforms each frame | Writes transform through refs inside requestAnimationFrame, not through React renders. |
| Image loader | Width ladder, URL building, decode before swap, neighbour preload, the decoded cache | Shared by the visible slide and preloading, so one photo is never fetched twice. |
| Transition engine | FLIP open and close, clip for cropped thumbnails, reduced motion | Needs the host's thumbnail elements, which only the host knows. |
| History sync | The photo parameter, pushState, replaceState, popstate | Keeps browser history rules away from the UI. |
Two speeds of state
During a pinch, the scale changes 60 times a second. If that went through React state, every frame would re-render the toolbar, caption and three slides, and a mid-range phone would drop frames. So the live view (scale, x, y, strip offset, backdrop opacity) sits in a plain mutable object, and the gesture controller updates it and schedules one requestAnimationFrame that writes the transforms.
When the gesture ends, the final zoom level is committed to the store once. That re-renders what depends on it: the zoom buttons' disabled state, the loader's choice of rung and the screen reader message.
How data moves
The lightbox does not fetch photo lists itself. The host page already has the items in its server cache for the grid, and it passes them in with the start index and a way to find each thumbnail element. Image bytes come straight from the image CDN by URL. The only writes are to the URL and, for long albums, a loadMore call back into the host's store.
Check your understanding · Exercise 7
Why does the slide strip write transforms through refs instead of rendering them from React state? Select all that apply.
Data model: what the viewer knows
Every item must carry its pixel width and height. Without them the viewer can't lay out the photo box before the image loads, so the FLIP animation has nothing to aim at and the layout jumps when the image arrives. PhotoSwipe requires them for the same reason. The alt field is required too: the type forces every team to write one.
type LightboxItem = {
id: string;
width: number; // original pixels, required
height: number;
alt: string; // required; describes the photo
caption?: string; // visible text under the photo
thumbSrc: string; // exactly the URL the grid used
src: (w: number) => string; // builds a CDN URL for a ladder rung
kind?: "image" | "tiled"; // tiled items get a deep zoom renderer
tileSource?: string; // DZI or IIIF info URL when tiled
meta?: { camera?: string; lens?: string; takenAt?: string };
};
type LoadState =
| { phase: "placeholder" } // thumbnail shown
| { phase: "screen"; width: number } // sharp at fit
| { phase: "zoom"; width: number } // sharp when zoomed
| { phase: "failed"; fallback: "thumb" | "screen" };
| Data | Lives in | Why |
|---|---|---|
| Items list and pages | Host's server cache (the same query the grid uses) | One copy; new pages show up in grid and viewer. |
| Open photo id | URL query parameter | Shareable, survives reload, Back closes. |
| Index, controls visible, zoom level after a gesture | Viewer store | Drives rendering a few times per second. |
| Live scale, x, y, strip offset | Mutable ref | Changes every frame during gestures. |
| Load state per item | Image loader map, keyed by id | Survives slide recycling; neighbours keep their state. |
| Decoded images | Loader cache with a limit | Bounded memory; see the performance chapter. |
The component API
Many teams will use this component: listings, reviews, the help center. The API has to make the common case one line and the unusual cases possible without forking. The component-api-design lesson covers controlled versus uncontrolled props in general.
Decision How do teams add features like thumbnails, captions or full screen?
What we pick: A small core with plugins and render slots, the model yet-another-react-lightbox uses. The core stays small and accessible, and the plugin contract is where the design system team keeps control. The headless hook can exist underneath for the rare team with a fully custom design.
<Lightbox
items={photos} // LightboxItem[]
index={openIndex} // controlled; null means closed
onIndexChange={setOpenIndex} // swipe, keys, buttons
onClose={() => setOpenIndex(null)}
getThumbnail={(i) => thumbRefs.current[i]} // for FLIP and focus return
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
urlParam="photo" // opt in to history sync
plugins={[zoom({ maxDetail: 2 }), captions(), counter()]}
render={{ slide: (item) => item.kind === "tiled" ? <TileViewer item={item} /> : undefined }}
labels={{ next: "Next photo", previous: "Previous photo", close: "Close" }}
/>
Check your understanding · Exercise 8
Why are width and height required fields on every item? Select all that apply.
Accessibility, captions and metadata
A lightbox is a visual tool, so it is easy to forget the people who can't see it, can't pinch, or can't hold a mouse still. The good news: most of the work is choices we already made. The modal-dialog guide covers the dialog itself; here is what is specific to a photo viewer.
Dialog, focus in and focus back
- Render the shell as a
dialogopened withshowModal(). The browser puts it in the top layer, makes the page behind inert, and sends Escape as acancelevent. Label it, for examplearia-label="Photo viewer". - On open, focus the close button, either with
autofocusor an explicitfocus()aftershowModal(). A viewer has no text to read first, so the way out is a sensible start; the modal-dialog guide explains how to choose the first focus in general. Screen readers then announce the dialog and the button, and Tab moves through the other controls. - On close, return focus to the thumbnail of the photo that is open now, not the one that opened the viewer. After swiping to photo 17, focus lands on thumbnail 17, which is also where the fly-back animation lands. The focus-management lesson explains why focus must always land somewhere meaningful.
A button for every gesture
WCAG 2.5.1 Pointer Gestures (level A) says that anything done with a multi-finger or path-based gesture must also work with a single pointer and no path. Pinch is multi-finger and a swipe is a path, so each needs a plain button: zoom in, zoom out and reset for pinch, previous and next for swipe, close for swipe down. The criterion's own examples are a map with plus and minus buttons and a carousel with previous and next buttons.
WCAG 2.2 adds 2.5.7 Dragging Movements (level AA): panning a zoomed photo by drag needs a way that does not drag. The Understanding document's example is a map with up, down, left and right buttons; for a photo, show pan buttons while zoomed, or let a click recenter the view on that point. Arrow keys pan for keyboard users, but they don't count here, because this rule is about pointers.
Announcing the photo
When the photo changes, screen readers do not announce it on their own: a changed src or alt is silent. Add a polite live region with the counter and the alt text, for example "Photo 4 of 24. Kitchen with an island and three stools." If the person holds an arrow key and flies through ten photos, wait about 300 ms after the last change before writing to the region, so they hear the photo they stopped on, not ten half messages.
Alt text and captions do different jobs. The alt describes what the photo shows for someone who can't see it, while the caption is visible text everyone reads, often adding context the photo can't show: "Renovated in 2024". Don't copy one into the other, or screen reader users hear the same sentence twice. Make alt required in the item type, so a missing description fails the build instead of shipping.
Controls that stay visible enough
- Photos can be white, black or busy, so put the toolbar and caption on a dark gradient scrim. Icon buttons need 3 to 1 contrast against what is behind them (WCAG 1.4.11) and text needs 4.5 to 1. The visual-accessibility lesson covers contrast in detail.
- Auto-hiding controls after a few seconds is fine for touch, but show them again on any key press or focus, and never hide them while a control has focus.
- With
prefers-reduced-motion: reduce, replace the fly from the thumbnail with a 150 ms fade, make swipes snap instead of slide, and drop inertia. Zoom still works; it just jumps to the target.
The info panel
Photo apps show camera, lens, exposure and date in an info panel, opened with an "i" button that has aria-expanded. Read this data on the server at upload and send it as plain fields in meta. Don't parse EXIF in the browser from the original file: it means downloading the original, and the original may hold the GPS position of someone's home. Strip location on upload unless the product shows it on purpose.
Check your understanding · Exercise 9
Which changes are needed for WCAG 2.5.1 Pointer Gestures in a lightbox that supports pinch and swipe? Select all that apply.
Performance: memory, layout and the first tap
Memory is the real budget
On the wire, photos look cheap: 120 to 420 KB each. In memory, a decoded image costs width times height times 4 bytes, whatever the file size. A viewer that keeps every photo it has shown at the 2560 rung holds 20 MB per photo, so 30 photos into an album it holds about 590 MB.
Mobile browsers kill tabs well before that, and there is no public limit to design against. So we set our own budget and enforce it in the loader.
| Approach | What is decoded | Memory on a laptop (2560 rung) |
|---|---|---|
| Originals for current and two neighbours | 3 by 4032 by 3024 | about 146 MB |
| Keep every visited photo | 30 by 2560 by 1920 | about 590 MB after 30 photos |
| Our window: current, 2 ahead, 1 behind, plus one zoom image | 4 by 2560 by 1920, plus 1 by 4032 by 3024 | about 127 MB at most, flat for any album |
| Same window on a phone (1280 rung) | 4 by 1280 by 960, plus one zoom image | about 68 MB at most |
JavaScript cannot free a decoded bitmap directly. What it can do is drop every reference and remove the element, so the browser is free to discard it. The loader keeps a map from item id to its Image and evicts by distance from the current index: anything more than 2 away is removed from the map, and if its download is still running, setting its src to an empty string lets the browser cancel it.
The zoom image is evicted as soon as its photo is no longer current. The tiled renderer draws with createImageBitmap, and there ImageBitmap.close() does release memory right away, so it closes tiles that leave the screen.
The first tap
The lightbox code, with gestures, animations and plugins, is a few tens of KB compressed. It should not be in the listing page's main bundle. But loading it only after the tap puts a network round trip between the tap and the animation. Interaction to Next Paint (INP) only measures until the next frame, so the metric may look fine while the person still waits.
The middle ground: start import() for the viewer when the browser is idle after the grid renders, or on the first pointerdown on any thumbnail, which comes a little before the click. PhotoSwipe splits itself the same way, into a small lightbox part and a core loaded on demand. The loading-strategies lesson covers this pattern. Add a preconnect to the image CDN's host in the page head, so the first screen-size image doesn't pay for a new connection.
No layout shift
- The viewer is in the top layer with fixed position, so opening it moves nothing on the page. The only shift risk is the scrollbar, handled with
scrollbar-gutter: stable. - Grid thumbnails have
widthandheightattributes, so the grid doesn't shift as they load, and the close animation lands on a stable target. - Scrolling the grid to the current thumbnail before close happens while the viewer still covers the page, so nobody sees it jump.
What to measure
Lab tests on a fast laptop hide the problems, so measure in the field with performance.mark() and send the numbers to real user monitoring. Track time from tap to the first sharp frame (target under 1 s at the 75th percentile on phones), how often a swipe lands on a photo that still shows its placeholder (target under 5 percent, which tells you if preloading works), zoom image fetches per open, and decode() failures by browser.
Check your understanding · Exercise 10
A support ticket says the album viewer crashes on older iPhones after a few dozen photos. Which changes help? Select all that apply.
Running it in the interview
A lightbox question is a component question, so the interviewer wants depth on interaction, loading and accessibility, not servers. In 45 minutes you can't cover everything in this guide. Pick the three hardest parts, say out loud that you are picking them, and go deep.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope questions: sizes of originals, devices, deep links, zoom depth, who uses the component. | Write the answers down. Say which ones change the design: huge images and deep links. |
| 5 to 10 | Draw the wireframe and the architecture boxes. | Shell, strip with three slides, gesture controller, zoom model, image loader, history sync. |
| 10 to 20 | Loading: the three sizes, the width function, decode before swap, zoom image on demand. | Do the laptop example out loud: 1093 CSS pixels at DPR 2 picks the 2560 rung. |
| 20 to 30 | Gestures and zoom: the decision step, pan bounds, handover at the edge, swipe down to close. | Draw the gesture state machine. |
| 30 to 38 | URL and Back, focus return, button alternatives for every gesture. | Name WCAG 2.5.1. Walk through the deep link close case. |
| 38 to 45 | API, memory budget, metrics. Leave time for follow ups. | Show the props and the eviction rule. |
Draw the gesture state machine and the loading ladder rather than describing them: both are easy to sketch and give the interviewer something to point at. When you make a choice, name the option you rejected and why in one sentence, for example "View Transitions would be less code, but the close has to follow the finger". That is the habit interviewers are listening for.
Follow-up questions to expect
Think first
Product now wants videos in the same gallery. What changes?
The shell, history, navigation, preloading window and focus handling stay. A video item gets its own slide renderer through render.slide.
Gestures change: zoom is off for video slides, and taps go to the player controls. The gesture controller asks the current slide whether it supports zoom.
Preloading changes: preload the poster image and maybe metadata, never the video itself. Pause the video when it stops being current.
Think first
On a slow 3G connection, people swipe faster than photos load. What do you do?
Keep showing the thumbnail as the placeholder, which is always there, so swiping never shows an empty slide.
When the person swipes past a photo whose screen image is still loading, cancel it by clearing its src, so the bandwidth goes to the photo they stopped on.
Respect saveData: preload only the next and previous photos, not two ahead, and use a smaller rung. Show a small loading indicator only after about 500 ms, so fast loads never flash it.
Think first
The page is server rendered. Should the open lightbox be server rendered for a deep link?
Render the page and a static version of the photo, an img with a proper srcset, so the photo is the Largest Contentful Paint element and shows before JavaScript loads.
Hydrate the viewer behavior on top. The opening animation is skipped for deep links anyway, so nothing is lost.
Check your understanding · Exercise 11
You have 10 minutes left and haven't talked about accessibility. What is the best use of the time? Select all that apply.
Summary
- Every photo has three sizes: the thumbnail you already have, a screen image picked by a width function, and a zoom image loaded only when zoom needs it.
- Use the decoded thumbnail as the placeholder, load the next size in a detached Image, await decode(), then swap: sharpening, never flashing.
- Animate open and close with FLIP and transform-origin 0 0, so swipe down to close can fly back from wherever the finger left the photo.
- Zoom is fit, detail (one image pixel per device pixel) and max, always around the pointer, with pan bounds that center small axes and clamp large ones.
- One gesture controller decides once per drag: pinch, pan, swipe or dismiss, with a handover from pan to swipe at the pan edge.
- Push one history entry on open, replace it while navigating, and route every close through Back; deep links close with replaceState.
- A button for every gesture (WCAG 2.5.1), focus back to the thumbnail of the current photo, and a polite live region for the counter and alt text.
- Keep decoded memory flat with a window of current, two ahead and one behind, plus at most one zoom image.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- HTMLImageElement: decode() method Wait for an image to be ready to paint before showing it.
- FLIP your animations Paul Lewis's original write-up of First, Last, Invert, Play.
- View Transition API The browser-run alternative for the open animation.
- Optimize resource loading with the Fetch Priority API Priority hints for the current photo and its neighbours.
- scrollbar-gutter Scroll lock without the page jumping sideways.
- Understanding SC 2.5.1: Pointer Gestures Why pinch and swipe need single-pointer alternatives.
- Understanding SC 2.5.7: Dragging Movements Alternatives for panning by drag.
- IIIF Image API 3.0 The URL scheme for tiled deep zoom of large images.
- OpenSeadragon An open source deep zoom viewer for DZI and IIIF images.
- PhotoSwipe documentation A mature lightbox; its docs explain item sizes, zoom levels, preloading and cropped thumbnails.
- yet-another-react-lightbox documentation A React lightbox built from a small core, plugins and render functions.
- Same-document view transitions How the snapshots animate, including aspect ratio changes from a thumbnail.
- Re-rastering composited layers on scale change Why a photo scaled under will-change: transform can stay blurry.
- Capping image fidelity on ultra-high resolution devices Twitter's measured savings from treating 3x screens as 2x.
Concepts behind it
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.