cf.completefrontend
My progress
SYSTEM DESIGN · #41

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

QuestionAnswer we assumeWhat 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 transform and opacity change 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.
Wireframe of the lightbox, a full-screen dialog. At the top left, the counter reads 3 of 24. At the top right are four buttons: zoom out, zoom in, info, and close. In the middle is the photo, fitted to the screen, with a Prev button on its left and a Next button on its right. At the bottom, on a dark scrim, is the caption, Kitchen with island, and a hidden live region that announces Photo 3 of 24.Photo viewer (dialog, top layer)3 of 24-+iClosePrevphoto, fittedtransform: translate scaleNextKitchen with islandlive region: Photo 3 of 24
Every gesture has a visible button: zoom out and in for pinch, Prev and Next for swipe, close for swipe down. The caption and counter sit on a dark scrim so they stay readable on any photo.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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?

Fade only

Mount the viewer with opacity: 0 and transition to 1.

The photo appears in its final place.

Pros

  • Two lines of CSS. Works everywhere.
  • Nothing can go wrong with positions.

Cons

  • No link between the thumbnail and the photo.
  • Close does not show where you are in the grid.

FLIP with the Web Animations API

Measure the thumbnail's box (First), render the viewer and measure the big photo's box (Last).

Compute the translate and scale that make the big photo sit exactly on the thumbnail (Invert).

Animate that transform back to none with element.animate() (Play).

Pros

  • Works in every current browser.
  • Uses only transform, so it runs on the compositor.
  • You own the animation: you can start the close from wherever a finger left the photo.

Cons

  • You write the math, including the case where thumbnail and photo have different aspect ratios.
  • Needs a clip for cropped thumbnails.

View Transitions API

Give the thumbnail view-transition-name: photo. Call document.startViewTransition() and, inside the callback, open the viewer and move the name to the big photo.

The browser snapshots old and new states and animates the box between them.

Pros

  • Very little code; the browser does the measuring.
  • Same-document transitions work in Chrome and Edge 111+, Safari 18+ and Firefox 144+.

Cons

  • The snapshots sit in an overlay above the page until the animation finishes, and the animation is hard to drive from a finger.
  • When the thumbnail is a square crop of a 3:2 photo, the default cross-fade looks wrong until you restyle the snapshot pseudo-elements (object-fit, overflow: clip).
  • Older browsers need a fallback anyway.

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.

The four FLIP steps for opening the lightbox, as four small screens. First: measure the thumbnail's box in the grid. Last: render the viewer and measure the photo's final box. Invert: apply translate and scale 0.3 so the photo sits exactly on the thumbnail. Play: animate the transform to none over 280 ms, so the photo grows to full screen.1. Firstmeasure thumb2. Lastmeasure photo3. Invertscale 0.34. Playanimate to none280 ms, transform only
Only 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.

ts
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)" },
  );
}
Measure, invert, play. With transform-origin at the top left, the math stays one scale and one translate.

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).

Sequence diagram of opening a photo, between the User, the Viewer, the Image loader and the image CDN. The user taps thumbnail 3. The viewer renders the thumbnail as a placeholder and plays the FLIP animation. The viewer asks the loader for item 3 at the 2560 rung. The loader requests the image with w=2560 from the CDN, which replies with 420 KB of AVIF. The loader awaits decode and tells the viewer to swap in the sharp image. On the failure path, the CDN returns an error or times out, and the loader tells the viewer to keep the thumbnail and show a Retry button.UserViewerLoaderCDNtap thumbnail 3thumb + FLIPload(3, 2560)GET ?w=2560420 KB AVIFdecoded: swaperror or timeoutkeep thumb, Retry
The person sees motion within one frame of the tap, because the thumbnail is already decoded. The sharp image only replaces it after decode() resolves, and a failure leaves a soft but correct photo with Retry, never an empty box.

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.

Above the 1280 rung, each step costs about 2 to 3 times the bytes of the one below it. The jump to the zoom rung is the one to delay.
RungPixelsBytes on the wireDecoded memoryUsed for
400400 by 300about 20 KB0.5 MBGrid thumbnail and open placeholder
12801280 by 960about 120 KB4.9 MBPhone screens, fitted
19201920 by 1440about 250 KB11 MBDesktop at 1x, small laptops at 2x
25602560 by 1920about 420 KB20 MBLaptops at 2x, fitted
40964032 by 3024 (capped)about 1.2 MB49 MBZoomed past the screen image only

Decision How does the viewer choose which image to load?

Always the original

One URL per photo, the camera file.

Pros

  • Simplest. Always sharp when zoomed.

Cons

  • 2 to 6 MB per photo and 49 MB decoded, even when shown at a quarter of the size.
  • Seconds of soft placeholder on phones.

srcset with sizes="100vw"

Give the img the whole ladder in srcset and let the browser pick for the viewport width and device pixel ratio.

Pros

  • Native, no JavaScript, browsers may reuse a bigger cached candidate.

Cons

  • 100vw is wrong for most photos: a 4:3 photo on a wide laptop is limited by height, so it shows at about 1,100 CSS pixels, not 1,440.
  • A fixed sizes knows nothing about zoom, so zooming never gets sharper. PhotoSwipe works around this by rewriting sizes as the person zooms.

Compute the width in JavaScript

Fit the photo's known aspect ratio into the viewer box to get its displayed CSS width.

Multiply by devicePixelRatio and the zoom level, round up to the next rung, and load that URL.

Pros

  • Exact for every shape of photo and screen.
  • Zoom is just another input to the same function.

Cons

  • You own the logic and the swap.
  • Must cap DPR and zoom so a 3x phone does not ask for 4096 just to fit.

Tiled deep zoom

Cut the original into a pyramid of 256 pixel tiles at halving resolutions. Load only the tiles on screen at the current zoom level.

Pros

  • Works for gigapixel images; memory depends on the screen, not the image.

Cons

  • Needs a tiling pipeline and a canvas or WebGL renderer such as OpenSeadragon.
  • Hundreds of requests, harder accessibility, heavy for normal photos.

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.

ts
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];
}
Capping DPR (device pixel ratio) at 2 can drop a 3x phone one rung: a 430 CSS pixel wide phone needs 1290 pixels uncapped (the 1920 rung) but 860 capped (the 1280 rung), less than half the pixels.

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.

The width ladder and which rung three devices pick for a fitted 4:3 photo. The rungs are 400 at 20 KB, 1280 at 120 KB, 1920 at 250 KB, 2560 at 420 KB, and 4096 at 1.2 MB. A phone needing 786 device pixels picks 1280. A desktop needing 1333 picks 1920. A laptop needing 2187 picks 2560. The 4096 rung is only for zoom.40020 KB1280120 KB1920250 KB2560420 KB40961.2 MBPhone: 786 pxDesktop: 1333 pxLaptop: 2187 pxneeded device pixelszoom only: 4096
Each device rounds its needed width up to the next rung, so the CDN only ever caches five sizes per photo. Nobody gets the 4096 rung until they zoom.

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.

ts
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";
}
The isCurrent check stops a slow response for photo 3 from landing on photo 4. Inserting the decoded element, as MDN suggests, is safer than setting the old element's src, which browsers usually but not always paint from the decoded cache.

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.

Device
Network
ZoomPreload
Loading lab on a laptop 1440 x 820, DPR 2 over 4G 9 Mbps at 1x fit. The photo needs 2187 device pixels and loads the 2560 rung: 420 KB, 19.7 MB decoded, sharp after about 472 ms. First pixels: 0 ms (thumbnail). 2 neighbours preloaded; total 1260 KB and 59 MB decoded.-2-12560now2560+12560+2needs 2187 px: rung 2560420 KB, 19.7 MB decodeddownload 443 + decode 29 msfirst pixels: 0 mspreloaded: 2on the wire: 1260 KBdecoded: 59 MBnext swipe: sharp
At 1x fit this screen needs 2187 device pixels, so the 2560 rung is enough. 2 neighbours are ready before the swipe.

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.

Fit and detail move in opposite directions across devices, so fixed zoom steps like 2x feel different everywhere.
DeviceFit for a 4032 by 3024 photoDetailDouble tap goes to
Phone, 393 CSS px wide, DPR 30.0970.3330.333, about 3.4 times fit
Laptop, 1440 by 820 box, DPR 20.2710.50.542, twice fit
Desktop, 1920 by 1000 box, DPR 10.3311.01.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.

ts
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,
  });
}
px and py are in viewer coordinates. The point under them maps to the same photo pixel before and after.

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.

Pan bounds in two cases. Left: the zoomed photo is larger than the screen on both axes, so each edge is clamped: the photo's left edge may not move right of the screen's left edge, and its right edge may not move left of the screen's right edge. Right: the photo at fit is narrower than the screen, so it is centered, with equal space on both sides.screenphoto is largerlarger: clamp edgesphoto5050smaller: centerdrag past a bound: move 1/3, spring back
Each axis is checked on its own. Larger than the screen: 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?

Separate handlers per element

The strip listens for swipes, the photo for pans and pinches.

Call stopPropagation to resolve conflicts.

Pros

  • Each piece is simple and reusable.

Cons

  • Conflicts are resolved by DOM order, not by intent.
  • No way to hand a drag from pan to swipe at the pan edge.

One gesture controller with a decision step

One set of pointer listeners on the viewer root with touch-action: none.

After the finger moves past a small threshold, decide once: pinch, pan, swipe or dismiss. Route all later moves to that one handler until the last pointer lifts.

Pros

  • One place to read the rules and test them.
  • Can hand over at the pan edge.
  • Never two gestures at once.

Cons

  • A central piece every feature must go through.
  • The rules need tuning on real devices.

Native scroll and scroll snap for the strip

Make the strip a horizontal overflow: auto container with scroll-snap-type: x mandatory.

Only zoom is custom.

Pros

  • Native momentum and accessibility of scrolling.

Cons

  • Fighting the browser when zoomed: you must turn scrolling off and on mid-gesture.
  • Swipe down to close still needs custom code.

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).

State machine of the gesture controller. From idle, pointerdown goes to pending, which waits until the finger moves 10 pixels. From pending it decides exactly one gesture: pinch if a second pointer lands, pan if the photo is zoomed past fit, swipe if at fit and moving mostly sideways, dismiss if at fit and moving mostly down, or tap if the finger lifts before moving 10 pixels. When the last pointer lifts, every state returns to idle. A pan pinned at its edge becomes a swipe for the rest of the drag.idlependingunder 10 pxdownpinchsecond pointer landspanzoomed past fitswipefit and mostly sidewaysdismissfit and mostly downtaplifted under 10 pxlast pointer up: back to idlepan pinned at edge: becomes swipeone decision per drag
The decision happens once, in pending, and lasts until the last finger lifts. The one allowed change mid-drag is pan to swipe at the pan edge.

The decision rules

  1. A second pointer lands before the decision: pinch. If it lands after a swipe started, ignore it.
  2. Wait until the finger moves 10 CSS pixels from where it started. Before that, it may still be a tap.
  3. 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.
  4. If the photo is at fit and the movement is mostly horizontal (the x distance is more than the y distance): swipe to navigate.
  5. 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.
  6. 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.

Photo
Drag
Gesture lab. The photo is at fit. A one finger drag left of 60 pixels is decided as swipe. While dragging: strip moves 60 px. On release: snap back (needs 98 px or a flick).prevnextphotodecision: swipescreen: 393 pxrelease: snap back
At fit and moving mostly sideways: the strip follows the finger.

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: hidden on the html element 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: stable on html, 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 measuring window.innerWidth - document.documentElement.clientWidth and adding it as right padding.
  • Inside the viewer, touch-action: none stops the browser from scrolling or zooming the page on touch, and overscroll-behavior: contain on 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.

Architecture of the lightbox. The host page holds the photo grid and the items cache. The grid gives the Shell the items, start index and thumbnails; the Shell can call loadMore on the cache. Inside the lightbox: the Shell (dialog and focus), the Viewer store, the Gesture controller, the Slide strip, the Zoom and pan model, the Transition engine, History sync and the Image loader. The gesture controller sends intents to the store and writes frames to the strip through requestAnimationFrame, using the zoom model. The store drives the strip. The image loader fetches bytes from the image CDN, and History sync reads and writes the photo parameter in the browser URL.Host pagePhoto gridItems cacheLightboxShelldialog, focusGesture ctrlZoom modelpure mathHistory syncViewer storeSlide stripTransitionFLIPImage loaderrAFImage CDNBrowser URLbytes?photo=idloadMore
Two speeds: the gesture controller writes frames straight to the strip, and only commits to the store when a gesture ends. The only parts that talk to the outside world are the image loader (CDN) and history sync (URL).
PartOwnsWhy it is separate
Lightbox shellThe modal dialog, top layer, focus trap and return, scroll lock, backdropSame for every gallery. Reuses the modal-dialog design.
Viewer storeOpen or closed, current index, controls visible, zoom level after each gestureThe single source of truth that React renders from. Changes a few times per second at most.
Gesture controllerPointer, wheel and key input, the decision step, velocitiesOne place for the arbitration rules. Emits intents like pan, zoomAt, next, dismiss.
Zoom and pan modelFit, detail and max levels, zoomAt, clampPan, inertia stepsPure functions: unit tested without a browser.
Slide stripThree recycled slides, the strip offset, writing transforms each frameWrites transform through refs inside requestAnimationFrame, not through React renders.
Image loaderWidth ladder, URL building, decode before swap, neighbour preload, the decoded cacheShared by the visible slide and preloading, so one photo is never fetched twice.
Transition engineFLIP open and close, clip for cropped thumbnails, reduced motionNeeds the host's thumbnail elements, which only the host knows.
History syncThe photo parameter, pushState, replaceState, popstateKeeps 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.

Data flow of the lightbox. Top row: the listing API sends a JSON page of items to the host cache, which passes items and the index to the viewer store; the store calls loadMore back on the cache for the next page. The store sends the current index down to the slide. Middle row: the slide asks the image loader for an item id and rung; the loader requests the image from the image CDN, which returns AVIF bytes; the loader passes a decoded image to the slide. The store writes the photo id to the browser URL.Listing APIHost cacheViewer storeJSONitemsloadMoreImage CDNImage loaderSlideindexAVIFdecodedGET ?w=id, rung?photo=idBrowser URL
Item data and pixels travel separately: JSON through the host's cache, image bytes straight from the CDN by URL. The lightbox writes only two things: loadMore for the next page, and the photo id in the URL.

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.

ts
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" };
src is a function, so the viewer can ask for any rung without knowing the CDN's URL format.
DataLives inWhy
Items list and pagesHost's server cache (the same query the grid uses)One copy; new pages show up in grid and viewer.
Open photo idURL query parameterShareable, survives reload, Back closes.
Index, controls visible, zoom level after a gestureViewer storeDrives rendering a few times per second.
Live scale, x, y, strip offsetMutable refChanges every frame during gestures.
Load state per itemImage loader map, keyed by idSurvives slide recycling; neighbours keep their state.
Decoded imagesLoader cache with a limitBounded memory; see the performance chapter.
State machine of one item's image in the loader. Placeholder goes to screen when the screen image is decoded. Screen goes to zoom when the person zooms past the rung and the zoom image is decoded. Zoom goes back to screen when the person leaves the photo. Placeholder goes to failed on an error, and failed goes back to placeholder on Retry. Items more than 2 photos away are evicted back to placeholder, and a failed zoom load stays on the screen image.placeholderscreenzoomfaileddecodedzoom inleaveerrorRetryevicted when 3+ photos away:drop the Image, back to placeholdera failed zoom stays on screen
The thumbnail is always there underneath, so the worst case is a soft photo, never an empty one. A failed zoom load is not an error state at all: the screen image is still correct.

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?

One component with many boolean props

showThumbnails, showCaptions, enableFullscreen, enableZoom, and so on.

Pros

  • Easy to discover. One import.

Cons

  • Every team pays the bundle cost of every feature.
  • Props keep growing, and combinations break.

A small core with plugins and render slots

The core does open, close, navigate and swipe. plugins={[zoom, captions, thumbnails]} adds features.

render.slide and render.toolbar replace parts.

Pros

  • Unused features are not in the bundle.
  • Custom slides (video, tiled) without touching the core.

Cons

  • A plugin contract to design and keep stable.
  • Slightly more setup for the simple case.

Headless hook only

useLightbox() returns state, handlers and props getters. Teams build their own markup.

Pros

  • Total freedom of look.

Cons

  • Every team re-implements accessibility and animation, and gets some of it wrong.

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.

tsx
<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" }}
/>
Controlled index, so the host can open the viewer from anywhere, including a deep link. Returning undefined from render.slide falls back to the default image slide.

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 dialog opened with showModal(). The browser puts it in the top layer, makes the page behind inert, and sends Escape as a cancel event. Label it, for example aria-label="Photo viewer".
  • On open, focus the close button, either with autofocus or an explicit focus() after showModal(). 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.

The window keeps memory flat. The single zoom image is the biggest item, so it is the first thing we drop.
ApproachWhat is decodedMemory on a laptop (2560 rung)
Originals for current and two neighbours3 by 4032 by 3024about 146 MB
Keep every visited photo30 by 2560 by 1920about 590 MB after 30 photos
Our window: current, 2 ahead, 1 behind, plus one zoom image4 by 2560 by 1920, plus 1 by 4032 by 3024about 127 MB at most, flat for any album
Same window on a phone (1280 rung)4 by 1280 by 960, plus one zoom imageabout 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.

Decoded memory for four approaches, as bars. Originals for the current photo and two neighbours: 146 MB. Keeping every visited photo at the 2560 rung after 30 photos: 590 MB. Our window of four screen images plus one zoom image on a laptop: 127 MB at most. The same window on a phone at the 1280 rung: 68 MB at most.originals x3146 MBkeep all 30590 MBwindow, laptop127 MBwindow, phone68 MBdecoded = width x height x 4 bytes
Bytes on the wire hardly matter here. Decoded size is width times height times 4, and only a fixed window keeps it flat as the album grows.

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 width and height attributes, 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.

MinutesWhat to doWhat to draw or say
0 to 5Scope 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 10Draw the wireframe and the architecture boxes.Shell, strip with three slides, gesture controller, zoom model, image loader, history sync.
10 to 20Loading: 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 30Gestures and zoom: the decision step, pan bounds, handover at the edge, swipe down to close.Draw the gesture state machine.
30 to 38URL and Back, focus return, button alternatives for every gesture.Name WCAG 2.5.1. Walk through the deep link close case.
38 to 45API, 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?

Think first

On a slow 3G connection, people swipe faster than photos load. What do you do?

Think first

The page is server rendered. Should the open lightbox be server rendered for a deep link?

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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next