cf.completefrontend
My progress
SYSTEM DESIGN · #36

Design an avatar image cropper

The "crop your new profile photo" dialog from GitHub, Slack, LinkedIn and Google accounts: show a photo under a round or square frame, let people drag, zoom and rotate it with mouse, touch and keyboard, then cut out a small square image. The hard parts are the coordinate math between screen pixels and photo pixels, gestures that feel native on every device, and a 48 MP phone photo that needs about 195 MB of memory once decoded.

The question

Design the component people use to set their avatar: after they pick a photo, a dialog shows it under a circle, they move and zoom it until their face sits in the middle, and they press Save.

It must work with a mouse, a trackpad, a phone and a keyboard, and with huge photos straight from a phone camera. Walk me through the interaction, the math, how you make the final image, and the API other teams will use.

Real-life examples: Changing your profile picture on GitHub, Setting a profile photo in Slack, The LinkedIn profile photo editor with zoom and straighten, Choosing a Google account picture, Libraries like react-easy-crop, Cropper.js and react-image-crop

Requirements: a small dialog with very big photos

Almost every product with accounts has this dialog. You click your avatar, choose a photo, and a window opens with the photo behind a round hole. You drag the photo until your face is in the hole, maybe zoom in a little, and press Save. A second later, a crisp small circle with your face shows up in the header.

It looks like one of the smallest components in a design system, but it is not. The input is often a photo straight from a phone camera: 4032 by 3024 pixels or bigger, maybe in a format the browser can't open, maybe stored sideways with a note that says "rotate me", maybe with the GPS position of the person's home inside. The output is a tiny 400 by 400 image. Everything in between is the design.

This guide is about the cropper. Picking the file, checking its type and size, and sending bytes to the server are covered in the file-uploader guide; here we start the moment a File object is in our hands and stop when we hand a small Blob back.

Questions to ask

QuestionAnswer we assumeWhat it changes
What shape is the result?A square image. The UI shows a circle, but the file is square.We mask the view, not the pixels. Other teams can show the square as a circle with CSS.
What size is the output?400 by 400 pixels, about 20 to 60 KB.Fixes the output canvas size and quality settings.
Which inputs?JPEG, PNG, WebP, and HEIC from iPhones.HEIC needs a plan for browsers that can't decode it.
How big can the photo be?Up to 48 MP from new phones, up to 20 MB as a file.Decoded memory, not file size, is the limit. We downscale early.
Which devices?Desktop with mouse or trackpad, phones and tablets with touch, keyboard only.Pointer events, pinch, wheel, and a full keyboard path.
Rotation?90 degree turns, plus a small straighten slider as a stretch goal.Rotation changes the math for clamping and the source rectangle.
Other aspect ratios?Yes: the same component crops 3:1 profile banners.Aspect is a prop; nothing assumes a square.
Does the server trust the result?No. It decodes, re-encodes and resizes every upload.The client output is a convenience, not a security boundary.

Functional requirements

  • Open with a chosen image and show it under a fixed frame, round or square, with the area outside the frame dimmed.
  • Pan by dragging with a mouse, a pen or one finger. Zoom with a slider, the mouse wheel, a trackpad pinch and a two finger pinch on touch screens.
  • Rotate in 90 degree steps. Optionally straighten by up to 45 degrees either way.
  • Never show empty space inside the frame: the photo always covers it.
  • Show live previews of the result at the sizes it will be used, for example 96, 40 and 20 pixels.
  • Save produces a square image of a fixed size, plus the crop rectangle in source pixels for the server.
  • Every gesture has a keyboard equivalent. People can also skip cropping and use a centered crop.

Non-functional requirements

  • Smooth: dragging and pinching update at the display rate, so each frame has about 16 ms at 60 Hz and 8 ms at 120 Hz. No React re-render of the whole dialog per pointer move.
  • Fast to open: the photo appears within about 500 ms of picking it on a mid-range phone, even for a 12 MP photo.
  • Memory safe: a 48 MP photo must not crash a phone tab. We keep at most one large decoded copy alive, and free it on close.
  • Private: the output carries no EXIF data, so no GPS position or camera serial number leaves the device inside the avatar.
  • Accessible: a proper modal dialog, a labelled zoom slider with a spoken value, keyboard panning, and changes announced without noise.
  • Small: the cropper is loaded only when the dialog opens. Target under 15 KB of JavaScript, gzipped, for the component itself.

Out of scope

  • Picking, validating and uploading the file: the file-uploader guide covers it.
  • Filters, brightness and stickers. LinkedIn has them, but they are a photo editor, not a cropper.
  • Face detection to center the crop automatically. A nice follow-up; we'll mention where it plugs in.
  • Cropping video or animated GIFs. We take the first frame of an animated image.
Wireframe of the crop dialog, titled Crop your new profile photo. On the left, a stage with the photo, dimmed everywhere except a round frame in the middle. Below it, a Zoom slider with its knob, and Rotate and Reset buttons. On the right, three round previews at 96, 40 and 20 pixels, a hidden live region that says Zoom 150 percent, and the Skip, Cancel and Save buttons.Crop your new profile photoframedrag, pinch, wheeldimmedZoomRotateResetPreview964020live region (hidden):Zoom 150 percentSkipCancelSave
The frame stays still and big; the photo moves under it. The previews answer the question the big frame can't: is the face still readable at 20 pixels next to a comment?

Check your understanding · Exercise 1

Which answers from the interviewer would change the design in a real way (select all that apply)? Select all that apply.

What makes this hard

Four traits of this problem drive every later decision. Name them early in the interview; they are the map for the rest of the answer.

  1. Three coordinate spaces. The photo has its own pixels (4032 by 3024). The screen shows it scaled down to fit a 320 pixel stage, then zoomed and moved. The output is a third size (400 by 400). Every pointer move is in screen pixels, and every saved number must be in photo pixels. Mixing them up is the most common bug in real croppers.
  2. Huge inputs, tiny outputs. A 12 MP photo is 3 to 5 MB as a JPEG file but about 48 MB once decoded to pixels, because each pixel takes 4 bytes (red, green, blue, alpha). The result we keep is about 640,000 bytes of pixels and maybe 40 KB as a file. Most of the memory and time goes into a picture we mostly throw away.
  3. Many input devices, one feel. A mouse drags and scrolls, a trackpad pinches by sending wheel events with ctrlKey set, a phone sends two touch pointers, a pen has pressure we don't need, and a keyboard has arrows. All must move the same two numbers (position and zoom) with the same rules.
  4. Hidden image metadata. Phone photos carry EXIF data: the orientation flag that says the pixels are stored sideways, the GPS position, the camera model and the time. We must respect the first and remove the rest.

A fifth trait is easy to miss: the output is not the end. The server will decode the image again, re-encode it, and make several sizes (for example 460, 96, 40 and 20 pixels). That means the client output can favour quality and privacy over tiny size, and it means the client should also send the crop rectangle, so the server can redo the crop from the original if it keeps one.

Three coordinate spaces. Every number in the cropper belongs to exactly one of them.
SpaceUnitsExample sizeWho uses it
Source (natural)Photo pixels4032 x 3024drawImage source rectangle, the server crop
Stage (screen)CSS pixels320 x 320 framePointer events, CSS transform, the frame
OutputOutput pixels400 x 400The output canvas and the uploaded file

Think first

Why not just show the photo with CSS object-fit: cover and let the server crop it to the center?

The interaction model: move the photo or move the box

Before any math, decide what moves. There are two families of croppers, and people feel the difference within a second of touching them.

The naive version is the one most of us would build first: show the whole photo, put a square box on top, and let people drag the box and its corner handles. It works on a desktop with a 1,000 pixel wide photo. Now try it on a phone: the photo is shrunk to fit a 360 pixel wide screen, so a face is maybe 60 pixels across.

The box handles are 12 pixel squares that a thumb covers completely. To crop tightly around the face, you drag a handle a few pixels at a time, and every drag also risks grabbing the box instead. The final avatar is cut from a 60 pixel area of the screen, so you can't see whether the eyes are sharp until after you save.

Decision What should move when people adjust the crop?

Fixed frame, moving photo

The frame (the round or square hole) stays fixed in the middle of the stage at a large size, for example 320 by 320 CSS pixels.

The photo sits under it. Dragging moves the photo, and zooming scales the photo. This is the react-easy-crop model, and the one many phone apps use for profile photos.

Pros

  • The area that becomes the avatar is always shown big, so people judge the result at a useful size.
  • Only two kinds of gesture: drag and zoom. Both map to big, easy targets (the whole stage) instead of tiny handles.
  • The aspect ratio is fixed by the frame, so there is no way to make a wrong shape.

Cons

  • People can't see the whole photo at once when zoomed in, so finding a face in a group shot takes some panning.
  • Needs clamping logic so the photo always covers the frame.

Fixed photo, resizable box

The whole photo is shown to fit the stage. A box with 8 handles sits on top; people drag the box and resize it from its corners and edges.

This is the react-image-crop model, and the model of image-crop-element, the open source element GitHub publishes for square avatar crops.

Pros

  • The whole photo stays in view, which is great for cropping documents, screenshots and wide banners.
  • Free aspect ratios are natural: just drag an edge.

Cons

  • The crop area is shown small. A 60 pixel wide face on a phone is hard to frame well.
  • Handles are small touch targets. Making them 44 pixels wide covers the photo.
  • Resizing from a corner while keeping a 1:1 aspect needs its own constraint math.

Both: box plus zoomable photo

A resizable box over a photo that can also be zoomed and panned. Cropper.js (version 1) works this way by default: the crop box is resizable, and the wheel or a pinch zooms the photo.

Pros

  • The most flexible tool. Good for a full photo editor.

Cons

  • Two things move, so every gesture is ambiguous: does a drag move the box or the photo?
  • Much more state and math for a dialog people use once a year.

What we pick: For avatars, pick the fixed frame with a moving photo. The frame shows the result large, gestures are simple, and the aspect can't go wrong. Keep the resizable box in mind for free-form crops like screenshots or documents, and say so in the interview: the choice depends on whether the output shape is fixed.

Two cropper models side by side. Left, fixed frame: a big square frame stays still while the larger photo behind it moves and zooms, so the face is shown at 320 pixels. Right, resizable box: the whole photo is shown small and fixed, with a small crop box and four corner handles on top, so the face is shown at about 60 pixels and the handles are 12 pixel targets.fixed frame, moving photoframephoto movesface shown at 320 pxresizable box, fixed photobox and handles moveface shown at about 60 px
Both end in the same source rectangle. The difference is what people look at while choosing it: a big frame they move the photo under, or a small box they drag over a small photo.

Show a circle, save a square

The frame looks round because the avatar is shown round, but the saved file should still be a square, for three reasons. First, image formats are rectangles anyway; a "round" PNG is a square with transparent corners, and JPEG can't store transparency at all, so the corners would turn black or white.

Second, the same image is shown as a rounded square in some places (GitHub shows organization avatars that way) and as a circle in others. Third, the person framed their face inside a circle that touches the square's edges, so a square crop holds everything they chose, plus four corners nobody will see when it is shown round.

So the circle is only a mask: a dimmed layer over the stage with a round hole cut out. One easy way is a single element sized to the frame with border-radius: 50% and a huge box-shadow spread in a dark, half transparent color. The shadow darkens everything outside the circle and the hole stays clear. It costs one element, needs no canvas, and changing cropShape from round to square only changes the border radius.

Check your understanding · Exercise 2

A team asks you to reuse the avatar cropper for cropping scanned receipts, where the user picks any rectangle. What is true (select all that apply)? Select all that apply.

The math: from screen pixels to photo pixels

The whole cropper state is three numbers: a scale s, a translation (tx, ty), and later a rotation. Everything on screen and in the output comes from them. Getting this small model right is most of the work, and it is the part interviewers probe hardest.

The model

  • W and H: the photo's natural size, its real pixel count, from bitmap.width or img.naturalWidth. Example: 4032 by 3024.
  • fw and fh: the frame size in CSS pixels, for example 320 by 320. The frame sits in the middle of the stage.
  • s: CSS pixels per photo pixel. At s = 0.1 a 4032 pixel wide photo is drawn 403 CSS pixels wide.
  • tx and ty: how far the photo's center sits from the frame's center, in CSS pixels. (0, 0) means centered.

On screen we draw the photo with that one transform: translate(tx, ty) scale(s) around its center. It can be a CSS transform on an image, or the same transform replayed on a stage canvas each frame, which is what this design does (the architecture chapter explains why). Either way, never animate width or left, because layout properties make the browser redo layout every frame.

Three coordinate spaces. Left, the source photo, 4032 by 3024 photo pixels, with a dashed crop square at x 1260, y 284, 1512 pixels wide. Middle, the stage in CSS pixels: a 320 pixel frame over the photo drawn at scale 0.2116 and moved down 100 pixels. Right, the output, 400 by 400 pixels. Arrows: times s from source to stage, times 1.25 from stage frame to output.source: photo px4032 x 3024crop 1260, 284, 1512× sstage: CSS pxframe 320s 0.2116, ty 100× 1.25output: px400 x 400one view, three sizes: never mix their units
Pointer events live in the middle space. drawImage and the server need the left one, and the uploaded file is the right one. Divide by s to go left, multiply to go right.

Clamping: the frame is always covered

Rule one: the photo must cover the frame, with no empty edges. That gives a smallest scale: the photo is W * s CSS pixels wide, and that must be at least fw; the same for height. So minScale = max(fw / W, fh / H), which for our 4032 by 3024 photo and a 320 square frame is max(0.0794, 0.1058) = 0.1058.

The short side (height) is the one that decides. That is why a landscape photo at minimum zoom fills the frame top to bottom with a bit spare left and right.

Rule two: the photo can only move as far as its spare width allows. At minScale the photo is 426.7 by 320 CSS pixels, so it has 106.7 pixels to spare horizontally: tx can go from minus 53.3 to plus 53.3, and ty must be 0. In general |tx| <= (W * s - fw) / 2 and |ty| <= (H * s - fh) / 2.

We show people a friendlier number: zoom = s / minScale, so the slider starts at 1x (just covering) whatever the photo size. And clamp after every change, not only on release. Some croppers let the photo rubber-band past the edge and spring back, which feels nice with touch; if you do that, still clamp the value you report and save.

ts
type Size = { w: number; h: number };
type View = { s: number; tx: number; ty: number; rot: 0 | 90 | 180 | 270 };

const turned = (img: Size, rot: number): Size =>
  rot % 180 === 0 ? img : { w: img.h, h: img.w };

export function minScale(img: Size, frame: Size, rot: number) {
  const r = turned(img, rot);
  return Math.max(frame.w / r.w, frame.h / r.h);
}

export function clampView(v: View, img: Size, frame: Size, maxZoom: number): View {
  const r = turned(img, v.rot);
  const lo = minScale(img, frame, v.rot);
  const s = Math.min(Math.max(v.s, lo), lo * maxZoom);
  const mx = (r.w * s - frame.w) / 2;
  const my = (r.h * s - frame.h) / 2;
  const clamp = (n: number, m: number) => Math.min(Math.max(n, -m), m);
  return { ...v, s, tx: clamp(v.tx, mx), ty: clamp(v.ty, my) };
}
The core model for 0, 90, 180 and 270 degrees. Rotating by 90 swaps the photo's width and height.

From the visible frame to a source rectangle

When people press Save, we need the part of the photo inside the frame, in photo pixels. Work it out in three steps. First, the frame is fw CSS pixels wide and each CSS pixel is 1 / s photo pixels, so the crop is fw / s photo pixels wide. Second, the frame's center is -tx CSS pixels from the photo's center (if the photo moved right, the frame sees further left), which is -tx / s photo pixels.

Third, the photo's center is at W / 2. Put together, and rounded to whole pixels that stay inside the photo:

ts
export function sourceRect(v: View, img: Size, frame: Size) {
  const r = turned(img, v.rot);
  const w = frame.w / v.s;
  const h = frame.h / v.s;
  const x = r.w / 2 - v.tx / v.s - w / 2;
  const y = r.h / 2 - v.ty / v.s - h / 2;
  const width = Math.min(Math.round(w), r.w), height = Math.min(Math.round(h), r.h);
  // Rounding each number on its own can push the edge 1 pixel past the photo.
  const fit = (n: number, size: number, max: number) => Math.min(Math.max(Math.round(n), 0), max - size);
  return { x: fit(x, width, r.w), y: fit(y, height, r.h), width, height };
}
The source rectangle for rotation 0. For 90 degree turns, compute it in the turned size, then map it back (or let the server rotate first).

Worked example. Zoom 2x means s = 0.2116, so the crop is 320 / 0.2116 = 1512 photo pixels square. Centered, it starts at x = 2016 - 756 = 1260 and y = 1512 - 756 = 756.

Now drag the photo down by 100 CSS pixels to bring a face from the top third into the frame: ty = 100, so y = 1512 - 472.5 - 756 = 283.5, which rounds to 284. The crop moved up the photo by 472 photo pixels, just as expected: moving the photo down shows a higher part of it.

ts
import { test } from "node:test";
import assert from "node:assert/strict";

const img = { w: 4032, h: 3024 };
const frame = { w: 320, h: 320 };

test("min scale covers the short side", () => {
  assert.equal(minScale(img, frame, 0).toFixed(4), "0.1058");
});

test("clamp keeps edges out of the frame", () => {
  const s = minScale(img, frame, 0);
  const v = clampView({ s, tx: 500, ty: -40, rot: 0 }, img, frame, 8);
  assert.equal(v.tx.toFixed(1), "53.3");
  assert.ok(Math.abs(v.ty) < 1e-9); // may be -0 or 1e-14
});

test("dragging down moves the crop up", () => {
  const s = minScale(img, frame, 0) * 2;
  const r = sourceRect({ s, tx: 0, ty: 100, rot: 0 }, img, frame);
  assert.deepEqual(r, { x: 1260, y: 284, width: 1512, height: 1512 });
});

test("90 degrees swaps the deciding side", () => {
  assert.equal(minScale(img, frame, 90), 320 / 3024);
});
Small, fast tests that pin the math. Run them with node:test or Vitest.

How far to let people zoom in

A fixed maxZoom of 3 (react-easy-crop's default) is fine for phone photos, but there is a better bound: stop when the crop gets smaller than the output in photo pixels. If the output is 400 by 400 and the crop is only 250 photo pixels wide, the browser must stretch 250 pixels into 400, and the avatar looks soft. So maxScale = fw / outputWidth, which is 320 / 400 = 0.8.

For the 12 MP photo that allows about 7.5x zoom; for a 500 by 500 photo from an old account it allows only 1.25x. Take the smaller of this and a fixed cap, and say why in the interview: it ties a UI limit to output quality.

Rotation: 90 degree steps and free straightening

Turning by 90 degrees is easy: swap W and H in the math, as turned does, and recompute minScale. The photo that was landscape is now portrait, so the deciding side changes and the old scale may now leave gaps; clamp right after the turn. Keep the zoom ratio (not the raw scale) across the turn so the photo doesn't jump in size.

Free rotation (LinkedIn calls it straighten) is harder, because the photo is no longer lined up with the frame. The trick is to look at the frame from the photo's point of view. Rotate the frame by minus the angle: it becomes a tilted rectangle inside the photo, and the smallest upright box around it is bw = fw * |cos| + fh * |sin| wide and bh = fw * |sin| + fh * |cos| tall.

The photo covers the frame exactly when it covers that box. So minScale = max(bw / W, bh / H), and the translation clamp uses bw and bh instead of fw and fh, after turning (tx, ty) into the photo's own axes.

ts
export function clampTilted(s: number, tx: number, ty: number, deg: number,
  img: Size, frame: Size) {
  const a = (deg * Math.PI) / 180;
  const c = Math.abs(Math.cos(a)), n = Math.abs(Math.sin(a));
  const bw = frame.w * c + frame.h * n;
  const bh = frame.w * n + frame.h * c;
  const scale = Math.max(s, bw / img.w, bh / img.h);
  // Offset of the frame center from the photo center, in the photo's axes.
  const u = (-tx * Math.cos(a) - ty * Math.sin(a)) / scale;
  const v = (tx * Math.sin(a) - ty * Math.cos(a)) / scale;
  const mu = (img.w - bw / scale) / 2, mv = (img.h - bh / scale) / 2;
  const cu = Math.min(Math.max(u, -mu), mu), cv = Math.min(Math.max(v, -mv), mv);
  // Back to screen axes.
  return { s: scale,
    tx: -(cu * Math.cos(a) - cv * Math.sin(a)) * scale,
    ty: -(cu * Math.sin(a) + cv * Math.cos(a)) * scale };
}
Clamping with any angle: test the frame's bounding box in the photo's own coordinates.

A 10 degree tilt on a 320 square frame gives a box of 320 * (0.985 + 0.174) = 371 pixels, so the photo must zoom in by about 16 percent just to cover the corners. That is why straighten sliders visibly zoom while you drag them. With a free angle there is no upright source rectangle in the original photo any more, so we report the crop the way react-easy-crop does: the angle plus a rectangle in the space of the rotated photo's bounding box. The server rotates first, then crops.

Photo
A 4032 by 3024 photo under a 320 pixel frame, rotated 0 degrees. Scale 0.1058, zoom 1.00x, translation 0.0 by 0.0 CSS pixels. Source rectangle: x 504, y 0, 3024 photo pixels square. The face is inside the frame. The output is 400 pixels, shrunk 7.6 times.stage (half size), frame 320 pxs = 0.1058, zoom 1.00xtx = 0.0, ty = 0.0angle = 0 degreessource x 504, y 0size 3024 x 3024 pxoutput 400 x 400shrink 7.6xface in frame
Start: zoom 1x, centered. Try moving the photo past its edge.

Check your understanding · Exercise 3

A 3000 by 2000 photo, a 300 by 300 frame, no rotation. Which statements are true (select all that apply)? Select all that apply.

Gestures: one model, many hands

Every input device ends up changing the same view: scale and translation. The design question is how to listen to all of them without writing the cropper four times.

InputWhat the browser sendsWhat it changes
Mouse drag, pen, one fingerpointerdown, pointermove, pointeruptx and ty by the pointer's movement
Mouse wheelwheel with deltaY, sometimes in lines (deltaMode 1)Scale, around the cursor
Trackpad pinchwheel with ctrlKey: true in Chromium, Firefox and Safari 15 or later; Safari also sends its own gesturechange eventsScale, around the cursor
Two finger pinch on touchTwo pointers with different pointerIdsScale by the distance ratio, pan by the midpoint
Keyboardkeydown on the focused stageArrows pan, plus and minus zoom
Zoom sliderinput on an input type=rangeScale, around the frame center

Decision How should the stage listen to drags and pinches?

Mouse events plus touch events

mousedown and mousemove for desktop, touchstart and touchmove for phones, two code paths.

Pros

  • Works in very old browsers.

Cons

  • Two code paths with different shapes: touch events carry a list of touches, mouse events don't.
  • Pens get emulated as mouse or touch, depending on the browser.
  • Phones also fire emulated mouse events after touches unless you cancel them.

Pointer events

One set of events for mouse, pen and touch: pointerdown, pointermove, pointerup, pointercancel. Each finger is its own pointer with a pointerId.

setPointerCapture keeps sending moves to the stage even when the pointer leaves it.

Pros

  • One code path, supported in every current browser.
  • Capture solves the "drag ends when the mouse leaves the box" bug for free.
  • Pinch is just "two pointers are down".

Cons

  • Needs touch-action: none on the stage, or the browser scrolls the page and cancels our pointers.
  • Trackpad pinch still arrives as wheel, so that path stays separate.

A gesture library

A library such as @use-gesture/react wraps pointer and wheel events and reports drag, pinch and wheel with offsets and velocity.

Pros

  • Handles many edge cases and offers momentum.

Cons

  • Another dependency and another mental model, for a stage with only two gestures.

What we pick: Use pointer events directly. The cropper needs only drag and pinch, which are about 60 lines on top of pointer events, and owning them means we can clamp inside the same handler. Add a separate wheel listener for mouse wheels and trackpad pinches.

Drag with pointer capture

On pointerdown, call stage.setPointerCapture(e.pointerId) and remember the pointer's position. On each pointermove, add the movement to tx and ty, then clamp. On pointerup or pointercancel, forget the pointer. pointercancel matters: the browser fires it when it takes the touch for itself, for example when a system gesture starts, and a cropper that ignores it ends up stuck in a dragging state.

Set touch-action: none on the stage in CSS. Without it, the first finger move on a phone scrolls the page, the browser sends pointercancel, and our drag dies after a few pixels. Only the stage gets this rule: the rest of the dialog must still scroll on small screens.

Sequence diagram of a drag. The user presses on the stage; the stage captures the pointer and starts a drag in the view ref. Many pointer moves follow; each adds its delta to the view and clamps it, and the view ref schedules one animation frame, which draws the stage and previews. On pointer up the view is committed and the parent gets onCropComplete with the area in source pixels. If the browser sends pointercancel instead, the stage ends the drag and the view ref goes back to the last committed view.UserStageView refFrameParentpointerdowncapture, startmoves (many)delta, clamp1 framedraw, previewspointeruponCropCompletepointercancelend, restore
Many moves, one draw per frame, one callback per gesture. pointercancel is the failure path: the browser took the touch, so we end the drag cleanly instead of waiting for an up that never comes.

Zoom around the cursor

When people zoom with a wheel or a pinch, the spot under their fingers should stay under their fingers. Zooming around the frame center instead makes the face slide away, and people have to chase it. The fix is short: find the photo point under the cursor before the zoom, and choose the new translation that puts it back under the cursor after. The figma guide uses the same idea for its canvas camera.

ts
function zoomAt(v: View, nextS: number, p: { x: number; y: number }): View {
  const k = nextS / v.s;
  return { ...v, s: nextS, tx: p.x - (p.x - v.tx) * k, ty: p.y - (p.y - v.ty) * k };
}

stage.addEventListener("wheel", (e) => {
  e.preventDefault();
  const lines = e.deltaMode === 1 ? 16 : 1;
  const speed = e.ctrlKey ? 0.01 : 0.002; // trackpad pinch sends small deltas
  const factor = Math.exp(-e.deltaY * lines * speed);
  const p = toFrameCoords(e.clientX, e.clientY);
  updateView((v) => clampView(zoomAt(v, v.s * factor, p), img, frame, maxZoom));
}, { passive: false });
Zoom so that the point p (relative to the frame center, in CSS pixels) stays still, then clamp.

Two details hide in that snippet. The listener is added with passive: false, because we call preventDefault() to stop the page from scrolling or zooming. React attaches its own onWheel as a passive listener, so preventDefault() there does nothing; use addEventListener in an effect. And the zoom uses Math.exp, so zooming in by one notch and out by one notch returns exactly to the start, which a plain s + delta would not.

Two finger pinch

Keep a small map from pointerId to the latest position. When a second pointer goes down, record the distance between the two and the current scale. On each move with two pointers down, the new scale is the starting scale times the current distance divided by the starting distance.

Zoom around the midpoint between the fingers, and also pan by how far the midpoint moved, so people can pinch and drag in one motion. When one finger lifts, fall back to a one finger drag from the remaining pointer's current position, or the photo jumps.

Keep each move under one frame

Pointer moves keep arriving for the whole drag, about once per display frame and on some devices more often. If every move calls a React setState that re-renders the dialog, the slider, the previews and the buttons, a mid-range phone misses frames. Keep the live view in a ref, redraw the stage and the previews in one requestAnimationFrame callback per frame, and commit to React state only when the gesture ends.

The updateView helper in the wheel snippet does exactly this: it writes the ref and schedules one frame. The commit at the end updates the slider position and calls onCropComplete. The wheel has no "end" event, so commit after about 150 ms without a new wheel event. The runtime-performance lesson covers long tasks and INP (Interaction to Next Paint), the metric this protects.

Check your understanding · Exercise 4

A tester reports: "On my phone, dragging the photo works for a moment, then the page scrolls instead." Which causes are likely (select all that apply)? Select all that apply.

Decoding big photos: memory, orientation and HEIC

The file people pick is compressed. To show it, the browser decodes it into a grid of pixels, 4 bytes each. File size says little about decoded size: a 3 MB JPEG and a 12 MB PNG of the same 12 MP photo both take the same memory once decoded.

Decoded memory is width times height times 4. This is the number to estimate in the interview, not the file size.
PhotoPixelsDecoded (x 4 bytes)Typical file
Screenshot1920 x 1080 (2 MP)8.3 MB0.3 to 2 MB PNG
Phone, 12 MP4032 x 302448.8 MB2 to 5 MB JPEG or HEIC
Phone, 24 MP5712 x 428497.9 MB4 to 8 MB
Phone, 48 MP full resolution8064 x 6048195 MB8 to 20 MB
Working copy, 2048 long side2048 x 153612.6 MBnot saved
Output avatar400 x 4000.64 MB20 to 60 KB

The naive cropper sets img.src = URL.createObjectURL(file) and shows the full photo, scaled by CSS. On a laptop this mostly works. On a phone with a 48 MP photo, the tab may hold 195 MB for the decoded image, possibly another copy on the GPU, and the canvas we draw into at Save.

Phone browsers kill tabs that use too much memory, and the person sees the page reload with their work gone. Even when it survives, the first decode of a big JPEG can take hundreds of milliseconds on a slow phone, and the dialog opens with a visible stall.

Decision How should we turn the picked file into something we can show and crop?

Full size in an img element

Point an img at an object URL and scale it with CSS. Call await img.decode() before showing it, so the decode doesn't land in the middle of an animation.

Pros

  • Simplest code. EXIF orientation is applied for free. Works with every format the browser can show.

Cons

  • Keeps the full decoded photo in memory the whole time the dialog is open: 49 to 195 MB.
  • Drawing it to a canvas at Save touches all those pixels again.

Downscaled ImageBitmap

createImageBitmap(file, { resizeWidth: 2048, resizeQuality: "high" }) decodes and shrinks in one async call, ideally in a worker. Pass only one side and the browser keeps the aspect ratio for the other; to pass the long side, read the size first from the file header.

Show the bitmap on a canvas, or transfer it back to the main thread, which is cheap because ImageBitmap is transferable.

Pros

  • The copy we keep is 12.6 MB whatever the source size.
  • Async and off the main thread, so the dialog stays responsive while it decodes.
  • resizeWidth and resizeHeight work in current Chrome, Firefox (98 and later) and Safari (15 and later).

Cons

  • The browser may still need the full image briefly while it shrinks it, so the peak is not gone, only short.
  • resizeQuality arrived in Firefox only in version 149 (2026), so older Firefox versions use their default filter and quality can differ a little.
  • We must call bitmap.close() ourselves to free memory at once.

Draw to a smaller canvas once

Decode with an img, draw it once onto a canvas of the working size, keep the canvas, drop the img and revoke its URL.

Pros

  • Works everywhere, including old browsers without the resize options.

Cons

  • The shrinking drawImage runs on the main thread, and a browser that hasn't finished decoding the img decodes it there too.
  • One big shrink step can look jagged; shrinking by halves in a few steps looks better and costs more time.

What we pick: Decode a downscaled ImageBitmap in a worker, 2048 pixels on the long side, and fall back to the canvas path if createImageBitmap throws. Then the open dialog holds about 13 MB instead of up to 195. For the final output we have a choice, covered in the next chapter: crop from this working copy (fast) or go back to the original file for the selected area (sharpest).

The working copy also changes the max zoom from the math chapter. On a 2048 by 1536 copy, the crop at 1x zoom is 1536 working pixels wide, and it must stay at least 400 for a sharp 400 pixel output, so zoom stops at about 3.8x. If people need more (finding one face in a group photo), the next chapter's "crop from the original" path lifts the limit.

Photo
Strategy
A 12 MP photo, 4032 by 3024, with the Resized ImageBitmap in a worker strategy. Kept while open: 12.6 MB. Peak: 61.4 MB. Estimated time to first frame about 427 ms, of which 0 ms blocks the main thread. Sharp zoom up to 3.8x. Output: 400 by 400, 0.64 MB of pixels, about 40 KB as a file.4032 x 3024 = 12.2 MP, 48.8 MB decodedkept while openpeakoutput pixels0.64 MB, about 40 KB as a file300 MB: risky on phoneskept: 13 MB, peak: 61 MBestimated first frame: 427 msmain thread blocked: 0 msdecode + shrink in a workerworking copy: 2048 x 1536sharp zoom up to 3.8x
Times are an estimate from a rough model of a mid-range phone (decoding at 40 MP per second, shrinking at 100 MP per second), not a measurement; real speed varies by device, format and browser. Decoded sizes are exact (width times height times 4); the bars leave out browser overhead.

Free what you hold. Revoke every object URL with URL.revokeObjectURL once the image has loaded, call close() on the bitmap when the dialog closes or a new photo is picked, and terminate the worker if the dialog won't be used again. A cropper opened five times without cleanup holds five decoded photos; the memory-leaks lesson shows how to find that in a heap snapshot.

EXIF orientation: don't rotate twice

Phone cameras often save pixels in the sensor's direction and write an orientation value (1 to 8) into the EXIF metadata, meaning "turn me 90 degrees to show me upright". For years browsers ignored it, so every cropper read the EXIF bytes and rotated the image itself. Then browsers changed: since 2020 (Chrome 81, Firefox 77, Safari 13.1), img elements apply the orientation by default, because CSS image-orientation defaults to from-image. Drawing that img to a canvas draws it upright too in current engines.

createImageBitmap also applies it by default in current browsers: its imageOrientation option defaults to "from-image" (Chrome 112, Firefox 111 and Safari 16 and later). The spec's only other value is "flipY", which flips the result vertically, mostly for WebGL textures. Older code may pass "none", the old name of "from-image"; the spec plans to reuse "none" to mean "ignore the orientation", so leave the option out.

So old code that still rotates by hand now rotates photos twice, and portrait selfies show up lying on their side. The rule today: trust the browser's orientation, delete the hand rotation, and use the oriented width and height the browser reports for all the math. Test with real sideways phone photos in each browser you support, because this is the bug that comes back when a library is upgraded.

HEIC from iPhones

iPhones save photos as HEIC by default. As of 2026, Safari 17 and later can decode it; Chrome and Firefox can't. The file-uploader guide notes that iOS often converts to JPEG when a page asks through a file input, but a HEIC file can still arrive from a Mac or a shared drive.

When createImageBitmap and img.decode() both fail, we have three choices. We can tell people to pick a JPEG (honest, but a dead end), or load a WebAssembly HEIC decoder on demand (works offline, but adds a download of a megabyte or more for a rare case). Or we upload the original to the server, which converts it to JPEG and returns a URL we crop from.

This design takes the server path. The server already decodes every format for re-encoding, so it is one more call, and the cropper only ever sees formats the browser can open.

Check your understanding · Exercise 5

Users on older Android phones report that the page reloads when they open the cropper with a new 50 MP photo. Which changes help (select all that apply)? Select all that apply.

Producing the output: canvas, format and the server

Save turns the view into a file. There are two different things we could send, and the strongest designs send both: a small finished image, and the numbers that describe the crop.

Decision Who cuts out the final image?

The server, from the original

Upload the original file and the crop rectangle. The server decodes, crops, resizes and stores.

Pros

  • The client does no image work at all. The original is kept, so people can re-crop later.

Cons

  • Uploads 3 to 20 MB instead of 40 KB: slow on mobile data, and the avatar can't appear until the server is done.
  • The original with its GPS data leaves the device, even if the server strips it later.
  • HEIC and orientation must be handled the same way on both sides, or the crop lands on the wrong part.

The client, a finished small image

Draw the crop onto a 400 by 400 canvas, encode it with toBlob or convertToBlob, upload about 40 KB.

Pros

  • Tiny, fast upload; the new avatar can show at once from the local blob.
  • What people saw is exactly what they get, because the same transform made both.
  • No EXIF in the output, so no location leaves the device.

Cons

  • Encoding quality differs a little between browsers.
  • No original kept, so re-cropping later needs a new upload.

Both: client image plus crop numbers

Upload the small client image as the avatar, with the crop rectangle and rotation as metadata.

Products that let people re-crop later also upload the original, in the background, to a private location.

Pros

  • Fast and private by default, with an option to re-crop from the original when the product needs it.

Cons

  • Two code paths to keep consistent: the client crop and the server crop must agree.

What we pick: Make the client image the avatar and send the crop numbers with it. The server never trusts the bytes: it decodes them, re-encodes them, strips any metadata, and makes the sizes it serves. Keep the original only if the product needs re-cropping.

Replay the screen transform on the output canvas

The cleanest way to draw the output is not to compute a source rectangle and call the nine argument drawImage. It is to replay exactly the transform the screen used, scaled from frame size to output size. Rotation, free angles and zoom all come along for free, and the output can't drift from what people saw.

ts
async function renderCrop(src: ImageBitmap, v: View & { deg: number },
  frame: Size, out = 400, type = "image/webp") {
  const outH = Math.round((out * frame.h) / frame.w);
  const canvas = new OffscreenCanvas(out, outH);
  const ctx = canvas.getContext("2d")!;
  if (type === "image/jpeg") {
    ctx.fillStyle = "#fff"; // JPEG has no alpha: transparent turns black
    ctx.fillRect(0, 0, out, outH);
  }
  ctx.imageSmoothingQuality = "high";
  ctx.translate(out / 2, outH / 2);
  ctx.scale(out / frame.w, out / frame.w);
  ctx.translate(v.tx, v.ty);
  ctx.rotate((v.deg * Math.PI) / 180);
  ctx.scale(v.s, v.s);
  ctx.drawImage(src, -src.width / 2, -src.height / 2);
  let blob = await canvas.convertToBlob({ type, quality: 0.85 });
  // Safari can't encode WebP and silently returns PNG instead.
  if (blob.type !== type) blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
  return blob;
}
Runs in a worker. src is the working copy; v.s is in CSS pixels per working pixel. out is the output width; the height follows the frame's aspect.

That last check is a real trap. When a browser can't encode the requested type, toBlob and convertToBlob don't fail: they return a PNG. Safari can't encode WebP (MDN's compatibility data still lists no support in 2026), so a 400 by 400 "WebP" from Safari is really a PNG of around 200 to 300 KB. Always look at blob.type.

JPEG at quality 0.85 gives roughly 30 to 50 KB for a 400 pixel face. Google's WebP study measured lossy WebP files 25 to 34 percent smaller than JPEG at similar quality.

  • Size: 400 by 400 covers an avatar shown up to 200 CSS pixels wide on a 2x screen. If the largest spot is a 260 pixel profile header, output 520. Pick the largest display size times 2, not the source size.
  • Transparency: photos don't need it, but organization logos often have a transparent background. Offer PNG or WebP output for logo croppers; for JPEG, fill a background color first, as the code does.
  • Sharpness: when the crop is much bigger than the output (1,500 pixels down to 400), one big shrink can look jagged where imageSmoothingQuality isn't supported. Halving in steps on a second canvas fixes it.
  • Worker: OffscreenCanvas works in workers in Chrome, Firefox and Safari 16.4 and later. Encoding a 400 pixel image is quick, but drawing from a big source can take tens of milliseconds on a phone, and keeping it off the main thread keeps the Saving spinner smooth. Fall back to a normal canvas where it is missing.
  • Sharpest path: when people zoomed past what the working copy supports, decode the original file again in the worker for this one draw, then close it right away. Peak memory is high for a moment, but nothing big stays alive.

What the server does anyway

The client output is a convenience, not a contract. Anyone can send any bytes to the avatar endpoint, so the server decodes the upload with an image library, rejects anything that isn't a real image, re-encodes it (which drops metadata and any hidden extra data), and makes the sizes the product serves, for example 460, 96, 40 and 20 pixels in WebP and JPEG. It stores them under a new, content-hashed URL so CDNs can cache them for a year, and returns that URL. The image-optimization lesson covers serving those sizes with srcset.

Think first

Someone presses "Skip cropping". What exactly do we upload?

Check your understanding · Exercise 6

Your Safari users' avatars are 250 KB while Chrome users' are 30 KB. Which statements are true (select all that apply)? Select all that apply.

Architecture: who owns what

With the hard parts decided, we can draw the boxes. The guiding rule: keep the math pure, keep the heavy pixels in a worker, and keep React for the things that change at human speed (buttons, slider, status), not at pointer speed.

Architecture. On the left, product code: AvatarSettings sends the File into the CropDialog and gets a Blob back, then PUTs it to the Avatar API, which stores sizes on the CDN. In the middle, the lazy-loaded CropDialog: CropStage sends deltas to useCropView, the view store; Controls send zoom and rotate to it; useCropView draws the Previews. On the right, the imageWorker takes decode and render jobs from the stage, and cropMath, a pure module, does the clamping for useCropView.AvatarSettingsproduct codeAvatar APIre-encode, sizesCDNFileBlobPUTCropDialog (lazy)CropStagepointer, wheel, keysuseCropViewref + committed stateControlsPreviewsdeltaszoom, rotateimageWorkerdecode, rendercropMathpure functionsjobsclampdraw perframe,commit ongesture end
Only the stage and the view store run at pointer speed. Heavy pixel work sits in the worker, the rules sit in a pure module, and the dialog never talks to the server: product code does.
PartResponsibilityWhy the boundary is here
AvatarSettings (product code)Opens the picker, opens the dialog, uploads the result, shows the new avatar.Product teams own where the avatar goes; the cropper doesn't know about our API.
CropDialogModal shell: title, focus trap, Cancel, Save, Skip, status messages.Reuses the design system dialog; see the modal-dialog guide.
CropStagePointer, wheel and key handlers; a canvas the size of the stage that redraws the bitmap with the view transform each frame; the round or square mask.The only part that runs at pointer speed. It draws from a frame callback, outside React.
ZoomSlider and RotateButtonsNative input type=range and buttons that change the same view.Plain controls, accessible by default, rendered by React on commit.
PreviewsSmall round canvases at 96, 40 and 20 pixels.The same draw function as the stage and the output, called in the same frame.
useCropViewHolds the live view in a ref and the committed view in state; clamps every change.One place for the rules, so no input path can skip clamping.
cropMathPure functions: minScale, clampView, zoomAt, sourceRect.No DOM, so it is unit tested in milliseconds.
imageWorkerDecodes the file to a working bitmap; renders and encodes the output.Keeps the 100 ms to 500 ms jobs off the main thread.

Why a canvas for the stage instead of a CSS transform on an img? Because one function, drawView(ctx, bitmap, view, frame, size), can then draw the stage, the three previews and the final output. What people see and what they get come from the same lines of code, and the decoded bitmap is drawn directly, without encoding it back into an image URL that the browser would decode a second time.

How data moves

Follow one photo through the system. The picked File goes to the worker, which returns a working ImageBitmap and the oriented natural size. The stage draws the bitmap and turns pointer input into view changes. On Save, the committed view and the bitmap go back to the worker, which returns a Blob.

Product code uploads the blob with the crop numbers, and the server answers with the URL of the processed sizes. The browser fetches those from the CDN the next time it shows the avatar.

Data flow. Top row: the picker gives a File of about 3 MB to the decode step in a worker, which gives a 2048 pixel ImageBitmap to the stage, where pointer input becomes a view. Down the right: on Save the view goes to the render step in the worker, which makes a 400 pixel Blob of about 40 KB. Bottom row, right to left: the Blob and crop numbers go to the Avatar API, which re-encodes and sends sizes to the CDN under hashed URLs. Finally pages load a 40 pixel avatar from the CDN.pickerFile, 3 MBdecodeworkerstageinput to viewFilebitmaprenderworkerview on SaveAvatar APIre-encodeBlob + cropCDNhashed URLssizespages load small sizes from the CDN, cached for a year
The big things (the File and the bitmap) never leave the device. What crosses the network is a 40 KB Blob and a few numbers going up, and small cached images coming back.

The dialog's states

The dialog is a small state machine, and writing it down catches bugs a pile of booleans hides: can people press Save while the photo is still decoding? What happens if they press Cancel during the upload? Each state decides which buttons work.

State machine of the crop dialog. Decoding goes to editing when the bitmap is ready, or to converting when decoding fails; converting goes back to decoding with a JPEG from the server. Editing goes to rendering on Save or Skip. Rendering goes to uploading with a Blob. Uploading goes to done on 200 OK, or to failed on an error. Failed goes back to uploading on Retry, or back to editing. Cancel works in every state.decodingeditingrenderingconvertingfaileduploadingdonereadySavefailsJPEGBlob200 OKerrorRetryeditCancel works in every state:abort, close the bitmap, return focus
Gestures never change the state, only the view. Failed keeps the Blob, so Retry skips rendering, and Back to edit keeps the person's crop.
  • decoding: the stage shows a skeleton; Save and Skip are disabled; Cancel works and terminates the decode.
  • converting: the browser couldn't decode (usually HEIC); the original goes to the server's convert endpoint, and on success we decode the JPEG it returns.
  • editing: everything works. Gestures don't change the state; they only change the view.
  • rendering and uploading: controls are frozen so the output matches what is on screen. Cancel during upload aborts the request with an AbortController.
  • failed: the dialog stays open with the person's crop intact and a message. Retry reuses the blob we already made; no need to render again.
Sequence diagram of Save with a failure. The user presses Save. The dialog asks the worker to render the view, and the worker returns a 38 KB Blob. The dialog calls the product's onSave with the Blob and the area, and the product sends PUT to the avatar API. The network fails, so the product's promise rejects and the dialog shows the failed state with the crop intact. The user presses Retry, the dialog calls onSave again with the same Blob, the product sends the PUT again, the API answers 200 with the size URLs, the promise resolves and the dialog closes.UserDialogWorkerProductAPISaverender(view)Blob, 38 KBonSave(blob)PUT avatarnetwork errorreject: failedRetryonSave againPUT avatar200, size URLsresolve: close
The failure path costs nothing to recover from: the Blob is kept, so Retry is one more PUT, with no second render and no lost crop.

Check your understanding · Exercise 7

Which design choices keep dragging smooth on a mid-range phone (select all that apply)? Select all that apply.

Data model and component API

What state exists, and where it lives

A cropper has very little state, but one question about it matters: in which units do we store the view? So far the math used screen units (s, tx, ty in CSS pixels). But the frame size changes: a phone rotates from portrait to landscape, a desktop window shrinks, and the 320 pixel frame becomes 260. If the stored view is in CSS pixels, the crop jumps to a different part of the face.

Decision How do we store the crop position?

Screen units

Store s, tx and ty exactly as the stage uses them.

Pros

  • No conversion on each frame.

Cons

  • Breaks when the frame resizes.
  • Means nothing to the parent or the server, who don't know the frame size.

Image units

Store the crop center as fractions of the oriented image (cx and cy from 0 to 1), the zoom ratio (1 means just covering), and the rotation.

Derive s, tx and ty from them and the current frame size whenever we draw.

Pros

  • Survives resizes and device rotation.
  • Independent of the working copy's size, so the same numbers work on the original.

Cons

  • A small conversion per frame, which costs microseconds.

What we pick: Store the view in image units. Screen units are a drawing detail derived each frame. The parent and the server get a pixel rectangle in the original's oriented pixels, computed from image units at the moment of the commit.

ts
type Source = {
  file: File;                 // what the person picked
  natural: Size;              // oriented size of the original, for example 3024 x 4032
  working: ImageBitmap;       // at most 2048 on the long side; closed on unmount
  workingRatio: number;       // working.width / natural.w, for example 0.508
};

type CropView = {
  cx: number;                 // crop center, 0 to 1 across the oriented image
  cy: number;
  zoom: number;               // 1 = just covers the frame
  rotation: number;           // degrees: 0, 90, 180, 270, or any angle with straighten
};

type AreaPixels = { x: number; y: number; width: number; height: number };

type DialogState =
  | { tag: "decoding" } | { tag: "converting" }
  | { tag: "editing" } | { tag: "rendering" }
  | { tag: "uploading"; blob: Blob } | { tag: "failed"; blob?: Blob; message: string };
The client-side model. Nothing here goes to a global store: it lives and dies with the dialog.
Data model sketch. Three notes on top: Source, living in the dialog, with the natural size 4032 by 3024, the working ImageBitmap and the ratio 0.508. CropView, live in a ref and committed to state, with center x 0.50 and y 0.34 as fractions, zoom 2 and rotation 0. DialogState, with decoding, editing, uploading and failed. Below, AreaPixels, derived on commit from Source and CropView: x 1260, y 284, 1512 square, in the original's oriented pixels, sent to the parent and the server.Source (dialog)natural 4032 x 3024working bitmapratio 0.508CropView (ref)cx 0.50, cy 0.34zoom 2.0rotation 0DialogStatedecoding, editinguploading, failedAreaPixels (derived)x 1260, y 284, 1512 squareoriginal's oriented pixelsto parent and server
Only two things are stored: the source and the view in image units. The pixel rectangle is computed when a gesture ends, in the original's pixels, so the working copy's size never leaks out.

The component API

Design it in two layers, as the component-api-design lesson suggests for anything other teams will bend in ways we can't predict. A low-level Cropper is only the stage: it takes a source and reports crops. A high-level AvatarCropDialog wraps it with the dialog, slider, previews, Skip and Save, and is what most teams use.

ts
type CropperProps = {
  src: File | Blob | string;
  aspect?: number;                   // width / height; 1 for avatars, 3 for banners
  cropShape?: "round" | "rect";      // the mask only; the output is always a rectangle
  minZoom?: number;                  // default 1 (just covering)
  maxZoom?: number;                  // default 3, lowered further by output size
  rotation?: number;                 // controlled when given
  view?: CropView;                   // controlled when given
  defaultView?: Partial<CropView>;   // uncontrolled start, for example a face center
  onViewChange?: (v: CropView) => void;          // at most once per frame
  onCropComplete?: (area: AreaPixels, view: CropView) => void; // on gesture end
  onError?: (e: CropError) => void;  // "decode-failed", "unsupported-type"
  labels?: Partial<CropperLabels>;   // every string, for translation
};

type AvatarCropDialogProps = Omit<CropperProps, "view" | "onViewChange"> & {
  open: boolean;
  output?: { size: number; type: "image/webp" | "image/jpeg" | "image/png"; quality?: number };
  previewSizes?: number[];           // default [96, 40, 20]
  onSave: (blob: Blob, area: AreaPixels, rotation: number) => Promise<void>;
  onCancel: () => void;
};
Close to react-easy-crop's API on purpose: people who know it feel at home.
  • Controlled or not. view with onViewChange gives the parent full control, for a reset button or an undo stack. Without it, the cropper keeps its own view. Supporting both is the standard pattern for inputs.
  • Two callbacks, two speeds. onViewChange fires at most once per frame, for parents that mirror the view live. onCropComplete fires once when a gesture ends, with the rectangle in the original's pixels, for anything expensive. Firing the expensive one on every move is the classic way to make a cropper slow.
  • onSave returns a promise. The dialog shows Saving while it waits and the failed state if it rejects. The product decides how to upload; the dialog decides what the person sees.
  • Pixels of what? area is always in the original's oriented pixels, not the working copy's, so the server can redo the crop from the original. Converting is one division by workingRatio, and it is the bug to test for.

The server calls

http
PUT /api/me/avatar
Content-Type: multipart/form-data; boundary=...
  image: (400 x 400 WebP or JPEG blob)
  crop:  {"x":1260,"y":284,"width":1512,"height":1512,"rotation":0,
          "sourceWidth":4032,"sourceHeight":3024}

200 OK
{ "avatarUrl": "https://cdn.example.com/a/9f2c1e.webp",
  "sizes": { "460": "...", "96": "...", "40": "...", "20": "..." } }

413 { "error": "too_large", "maxBytes": 5242880 }
415 { "error": "unsupported_type" }

POST /api/images/convert        (HEIC in, JPEG at most 2048 px out)
200 { "url": "https://uploads.example.com/tmp/7d1a.jpg", "width": 2048, "height": 1536 }
The avatar call, plus the conversion call used only when the browser can't decode the file.

PUT fits because each person has exactly one avatar and setting it twice gives the same result. The response carries a new URL with a content hash in its path, so every cached copy of the old avatar stays valid for the old URL, and the new one needs no cache purge. The file-uploader guide covers retries and progress for the upload itself.

Check your understanding · Exercise 8

A team reports that saved banners are cut in the wrong place, but only for big photos. Small photos are fine. What is the likely cause (select all that apply)? Select all that apply.

Accessibility, previews and the last 10 percent

A cropper is a gesture-heavy component, which makes it easy to build one that only works with a mouse. Every gesture needs a keyboard path, and the screen reader path needs its own thought, because screen readers handle arrow keys themselves.

The dialog

  • Use the design system's modal (the modal-dialog guide): aria-modal, a title like "Crop your new profile photo" wired with aria-labelledby, focus kept inside, and focus returned to the "Change photo" button on close. The focus-management lesson covers why that return matters.
  • Put first focus on the stage, with aria-describedby pointing to a short hint: "Drag or use arrow keys to move the photo. Use plus and minus to zoom."
  • Escape cancels, but if a drag or pinch is in progress, the first Escape only ends the gesture and restores the view from before it.
  • Offer Skip cropping and Reset as real buttons. Skip is the escape hatch for anyone who can't or won't fiddle with gestures.

Keyboard map

Key on the focused stageActionWhy this step size
Arrow keysMove the photo 10 CSS pixelsSmall enough to fine tune: at 1x, a 12 MP landscape photo has about 107 pixels to spare, so about 11 presses cross it
Shift plus arrowMove 50 CSS pixelsFast travel across a zoomed-in photo
Plus or equals, minusZoom in or out by 0.1x (10 percentage points), around the frame centerThe same step as the slider's arrow keys (its step is 0.1), so both feel the same
HomeReset: centered, 1xA quick way out after getting lost
Rotate buttonsTurn 90 degrees left or rightButtons, not letter keys, so they don't clash with screen reader shortcuts

Arrow keys on a focused div work for sighted keyboard users. Screen reader users are different: in their browsing mode, the screen reader takes the arrow keys to read the page, and our handler never sees them. Native form controls switch the screen reader into a mode that passes keys through.

So the reliable path is native controls: the zoom slider, plus two more range sliders, "Horizontal position" and "Vertical position", in an "Adjust position" section. With aria-valuetext they read as "Horizontal position, slider, 40 percent from the left", which describes the crop better than any gesture could. The keyboard-navigation lesson covers why native controls beat custom key handling.

tsx
<label htmlFor="crop-zoom">{labels.zoom}</label>
<input
  id="crop-zoom"
  type="range"
  min={1}
  max={maxZoom}
  step={0.1}
  value={view.zoom}
  aria-valuetext={labels.zoomValue(Math.round(view.zoom * 100))} // "150 percent"
  onChange={(e) => commit(zoomAtCenter(view, Number(e.target.value)))}
/>
The zoom slider: a native range with a label and spoken value text.

Announce changes once, not every frame. When a wheel, pinch or key changes the zoom, send "Zoom 150 percent" to a polite live region after the gesture commits. Don't announce changes made with the slider itself: the slider already speaks its new value, and a second message doubles the noise. After a 90 degree turn, announce "Rotated to 90 degrees".

Previews that answer the real question

The frame shows the crop at 320 pixels, but the avatar lives at 40 pixels next to a comment and at 20 pixels in a mention. At those sizes a face that fills 60 percent of the frame becomes a blur. Live previews at 96, 40 and 20 pixels, drawn in the same frame callback as the stage, let people check the real result while they drag.

The previews are decorative to screen readers (aria-hidden), with one visible "Preview" label. They are cheap: three tiny canvas draws per frame from the bitmap we already have.

Loading, visuals and observability

  • Load on demand. Import the dialog with a dynamic import() when people click "Change photo", and prefetch it on hover or focus of that button. Most page views never open the cropper, so it shouldn't cost them bytes.
  • Frame contrast. A white ring vanishes on a white wall; a dark ring vanishes on a dark sweater. Draw a two-tone ring (white over dark) so the frame edge stays visible on any photo.
  • Reduced motion. If the cropper eases back after a drag past the edge, skip the easing when prefers-reduced-motion: reduce is set.
  • Measure it. Log decode time, decode failures by file type, render time, and output bytes by browser. A chart of output bytes by browser would have caught the Safari WebP fallback on day one. Never log the image itself.
  • Content Security Policy. The cropper uses blob: URLs and a worker, so the page's CSP needs blob: in img-src and the worker's origin in worker-src.

Check your understanding · Exercise 9

A screen reader user says: "I can open the cropper and press Save, but I can't move the photo." Which fixes help (select all that apply)? Select all that apply.

Running it in the interview

This question looks small, so weak answers spend 40 minutes on props and never reach the math. Strong answers name the three coordinate spaces and the memory problem in the first five minutes, then spend the middle on the model and the gestures.

MinutesWhat to doWhat to draw
0 to 5Ask about output size, shape, devices, max photo size, rotation and HEIC.Nothing yet; write the numbers down.
5 to 10Choose fixed frame over resizable box, and say why for avatars.The wireframe: stage, frame, slider, previews, buttons.
10 to 20The model: scale, translation, min scale, clamp, source rectangle. Do one worked example out loud.Photo, frame and the three spaces with numbers.
20 to 28Gestures: pointer events, capture, touch-action, wheel with ctrlKey, pinch, frame callback.The drag sequence.
28 to 36Decoding and output: 48 MB per 12 MP, working copy, worker, EXIF, toBlob fallback, server re-encode.Architecture with the worker box.
36 to 45API, accessibility, and the follow-ups below.The props list.

Say the numbers out loud. "A 12 MP photo is 48 MB decoded, so we keep a 2048 pixel copy at about 13 MB" shows more than any diagram. And write minScale = max(fw / W, fh / H) on the board; interviewers remember the candidate who could derive it.

Think first

Follow-up: "How would you start the crop centered on the face?"

Think first

Follow-up: "Product wants people to re-crop their current avatar later without uploading again."

Check your understanding · Exercise 10

Which statements would you expect from a senior answer to this question (select all that apply)? Select all that apply.

Summary

  • Prefer a fixed frame with a moving photo for avatars: the result is shown large, there are only two gestures, and the aspect can't go wrong.
  • Show a circle, save a square. The circle is a mask; the file is a rectangle any product can round with CSS.
  • The whole cropper is three numbers. Cover the frame with minScale = max(fw / W, fh / H), clamp translation to the spare size, and turn the frame into a source rectangle with fw / s.
  • Use pointer events with capture and touch-action: none for drag and pinch, a non-passive wheel listener for wheels and trackpads, and zoom around the cursor.
  • Decoded memory is width times height times 4: 48 MB for 12 MP, 195 MB for 48 MP. Decode once into a 2048 pixel working copy in a worker, and close it when done.
  • Trust the browser's EXIF orientation today, and never upload the original as the avatar: a fresh canvas encode drops GPS data.
  • Draw the stage, the previews and the output with one function, check blob.type after encoding, and let the server re-encode anyway.
  • Every gesture has a keyboard or native control path, changes are announced on commit, and Skip cropping is always there.

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