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
| Question | Answer we assume | What 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.
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.
- 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.
- 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.
- Many input devices, one feel. A mouse drags and scrolls, a trackpad pinches by sending wheel events with
ctrlKeyset, 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. - 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.
| Space | Units | Example size | Who uses it |
|---|---|---|---|
| Source (natural) | Photo pixels | 4032 x 3024 | drawImage source rectangle, the server crop |
| Stage (screen) | CSS pixels | 320 x 320 frame | Pointer events, CSS transform, the frame |
| Output | Output pixels | 400 x 400 | The 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?
That is the skip path, and we keep it as an option. But faces are rarely in the center of a photo. A portrait shot on a phone puts the face in the top third, so a center crop often cuts off the forehead or shows a chin.
The cropper exists to let people choose which part of the photo matters. Everything else in this guide is about making that choice easy and the result exact.
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?
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.
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
WandH: the photo's natural size, its real pixel count, frombitmap.widthorimg.naturalWidth. Example: 4032 by 3024.fwandfh: 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. Ats = 0.1a 4032 pixel wide photo is drawn 403 CSS pixels wide.txandty: 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.
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.
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) };
}
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:
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 };
}
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.
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);
});
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.
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 };
}
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.
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.
| Input | What the browser sends | What it changes |
|---|---|---|
| Mouse drag, pen, one finger | pointerdown, pointermove, pointerup | tx and ty by the pointer's movement |
| Mouse wheel | wheel with deltaY, sometimes in lines (deltaMode 1) | Scale, around the cursor |
| Trackpad pinch | wheel with ctrlKey: true in Chromium, Firefox and Safari 15 or later; Safari also sends its own gesturechange events | Scale, around the cursor |
| Two finger pinch on touch | Two pointers with different pointerIds | Scale by the distance ratio, pan by the midpoint |
| Keyboard | keydown on the focused stage | Arrows pan, plus and minus zoom |
| Zoom slider | input on an input type=range | Scale, around the frame center |
Decision How should the stage listen to drags and pinches?
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.
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.
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 });
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.
| Photo | Pixels | Decoded (x 4 bytes) | Typical file |
|---|---|---|---|
| Screenshot | 1920 x 1080 (2 MP) | 8.3 MB | 0.3 to 2 MB PNG |
| Phone, 12 MP | 4032 x 3024 | 48.8 MB | 2 to 5 MB JPEG or HEIC |
| Phone, 24 MP | 5712 x 4284 | 97.9 MB | 4 to 8 MB |
| Phone, 48 MP full resolution | 8064 x 6048 | 195 MB | 8 to 20 MB |
| Working copy, 2048 long side | 2048 x 1536 | 12.6 MB | not saved |
| Output avatar | 400 x 400 | 0.64 MB | 20 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?
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.
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?
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.
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;
}
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
imageSmoothingQualityisn't supported. Halving in steps on a second canvas fixes it. - Worker:
OffscreenCanvasworks 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?
Still not the original. We run the same renderCrop with the default view: zoom 1x, centered. The output is the same 400 pixel square, with no EXIF and a known size. One code path for both cases means skipping can't leak GPS data or upload 20 MB.
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.
| Part | Responsibility | Why 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. |
CropDialog | Modal shell: title, focus trap, Cancel, Save, Skip, status messages. | Reuses the design system dialog; see the modal-dialog guide. |
CropStage | Pointer, 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 RotateButtons | Native input type=range and buttons that change the same view. | Plain controls, accessible by default, rendered by React on commit. |
Previews | Small round canvases at 96, 40 and 20 pixels. | The same draw function as the stage and the output, called in the same frame. |
useCropView | Holds 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. |
cropMath | Pure functions: minScale, clampView, zoomAt, sourceRect. | No DOM, so it is unit tested in milliseconds. |
imageWorker | Decodes 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.
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.
- 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.
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?
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.
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 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.
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;
};
- Controlled or not.
viewwithonViewChangegives 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.
onViewChangefires at most once per frame, for parents that mirror the view live.onCropCompletefires 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. onSavereturns 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?
areais 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 byworkingRatio, and it is the bug to test for.
The server calls
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 }
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 witharia-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-describedbypointing 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 stage | Action | Why this step size |
|---|---|---|
| Arrow keys | Move the photo 10 CSS pixels | Small 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 arrow | Move 50 CSS pixels | Fast travel across a zoomed-in photo |
| Plus or equals, minus | Zoom in or out by 0.1x (10 percentage points), around the frame center | The same step as the slider's arrow keys (its step is 0.1), so both feel the same |
| Home | Reset: centered, 1x | A quick way out after getting lost |
| Rotate buttons | Turn 90 degrees left or right | Buttons, 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.
<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)))}
/>
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: reduceis 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 needsblob:inimg-srcand the worker's origin inworker-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.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask about output size, shape, devices, max photo size, rotation and HEIC. | Nothing yet; write the numbers down. |
| 5 to 10 | Choose fixed frame over resizable box, and say why for avatars. | The wireframe: stage, frame, slider, previews, buttons. |
| 10 to 20 | The model: scale, translation, min scale, clamp, source rectangle. Do one worked example out loud. | Photo, frame and the three spaces with numbers. |
| 20 to 28 | Gestures: pointer events, capture, touch-action, wheel with ctrlKey, pinch, frame callback. | The drag sequence. |
| 28 to 36 | Decoding and output: 48 MB per 12 MP, working copy, worker, EXIF, toBlob fallback, server re-encode. | Architecture with the worker box. |
| 36 to 45 | API, 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?"
Detect faces in the worker on the working copy, with an on-device model or, where available, a browser face detection API. Turn the face box into a starting CropView: center on the face, zoom so the face fills about 60 percent of the frame, then clamp. Pass it through defaultView, so the cropper itself needs no change, and fall back to centered when no face is found.
Think first
Follow-up: "Product wants people to re-crop their current avatar later without uploading again."
Then we must keep the original. Upload it to private storage in the background after picking (stripped of metadata on the server), and save the crop numbers in image units with the avatar. Re-cropping opens the cropper with the stored original and defaultView set from the saved numbers. Because the numbers are in the original's oriented pixels, they still line up.
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 withfw / s. - Use pointer events with capture and
touch-action: nonefor drag and pinch, a non-passivewheellistener 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.typeafter 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.
References
- Pointer events (MDN) pointerId, capture, pointercancel and the pinch example.
- touch-action (MDN) Why the stage needs none.
- createImageBitmap (MDN) resizeWidth, resizeQuality and imageOrientation.
- image-orientation (MDN) The from-image default that ended hand-written EXIF rotation.
- HTMLCanvasElement: toBlob() (MDN) Types, quality and the PNG fallback.
- OffscreenCanvas (MDN) Drawing and encoding in a worker.
- Slider pattern (WAI-ARIA APG) Value text and keyboard behavior for the zoom control.
- react-easy-crop A fixed-frame cropper whose API inspired the one here.
- image-crop-element (GitHub) The resizable-box model, published by GitHub for square avatar crops.
- Element: wheel event (MDN) deltaY, deltaMode and passive listeners.
- WebP compression study (Google) Where the 25 to 34 percent saving over JPEG comes from.
Concepts behind it
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.