Image Cropper Lite
Machine-coding brief for a lite image cropper — crop box, drag/resize, aspect lock, canvas export, and state.
- machine-coding
- interview
- react
- canvas
Problem statement
Build a lite image cropper: load an image, show a rectangular crop selection, drag (and optionally resize) the crop, export a cropped image via canvas. Interviewers score coordinate math (display vs natural pixels), pointer handling, and a clear export API.
Requirements
Must have
- Accept image
Fileor URL - Display image scaled to container (contain)
- Draggable crop rectangle within bounds
- Export:
HTMLCanvasElement/ blob / dataURL of cropped region in source image coordinates - Keyboard-accessible nudge of crop (arrow keys) as baseline a11y
Should have
- Resize handles (corners)
- Fixed aspect ratio mode (1:1 avatar)
- Zoom slider (scale image under crop) — clarify if in scope
- Min crop size
Nice to have
- Rotate
- Touch pinch zoom
- Circle crop mask (still export square/circle bitmap)
Planning (5 minutes out loud)
- Two coordinate spaces — CSS pixels on screen vs naturalWidth/Height
- Scale factor —
naturalWidth / displayedWidth - Crop state —
{ x, y, width, height }in display pixels, convert on export - MVP — fixed-size box drag + export; then resize + aspect lock
- File read via
URL.createObjectURLwith revoke on cleanup
Architecture
ImageCropper
├── Stage (relative container)
│ ├── img
│ └── CropOverlay (box + handles)
├── Toolbar (aspect, zoom, reset)
└── exportCrop(img, crop, scale) → Blob
Types
type Rect = { x: number; y: number; width: number; height: number };
type ImageCropperProps = {
src: string;
aspect?: number | null; // width/height, null = free
onCropChange?: (crop: Rect) => void;
};
async function exportCrop(
image: HTMLImageElement,
cropDisplay: Rect,
displayWidth: number
): Promise<Blob> {
const scale = image.naturalWidth / displayWidth;
const sx = Math.round(cropDisplay.x * scale);
const sy = Math.round(cropDisplay.y * scale);
const sw = Math.round(cropDisplay.width * scale);
const sh = Math.round(cropDisplay.height * scale);
const canvas = document.createElement("canvas");
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh);
return new Promise((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png")
);
}
Implementation sketch
Load + intrinsic layout
function ImageCropper({ src, aspect = null }: ImageCropperProps) {
const imgRef = useRef<HTMLImageElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<Rect>({ x: 40, y: 40, width: 160, height: 160 });
const drag = useRef<null | { mode: "move" | "se"; startX: number; startY: number; origin: Rect }>(null);
useEffect(() => {
// when image loads, center a default crop
const img = imgRef.current;
if (!img) return;
function place() {
const w = img.clientWidth;
const h = img.clientHeight;
const side = Math.min(w, h) * 0.6;
const cw = side;
const ch = aspect ? side / aspect : side;
setCrop({
x: (w - cw) / 2,
y: (h - ch) / 2,
width: cw,
height: ch,
});
}
if (img.complete) place();
else img.addEventListener("load", place);
return () => img.removeEventListener("load", place);
}, [src, aspect]);
function clampCrop(r: Rect): Rect {
const w = imgRef.current?.clientWidth ?? 0;
const h = imgRef.current?.clientHeight ?? 0;
const width = Math.min(Math.max(40, r.width), w);
const height = Math.min(Math.max(40, r.height), h);
const x = Math.min(Math.max(0, r.x), w - width);
const y = Math.min(Math.max(0, r.y), h - height);
return { x, y, width, height };
}
function onPointerDown(mode: "move" | "se", e: React.PointerEvent) {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { mode, startX: e.clientX, startY: e.clientY, origin: crop };
}
function onPointerMove(e: React.PointerEvent) {
if (!drag.current) return;
const { mode, startX, startY, origin } = drag.current;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (mode === "move") {
setCrop(clampCrop({ ...origin, x: origin.x + dx, y: origin.y + dy }));
} else {
let width = origin.width + dx;
let height = origin.height + dy;
if (aspect) height = width / aspect;
setCrop(clampCrop({ ...origin, width, height }));
}
}
function onPointerUp() {
drag.current = null;
}
return (
<div ref={stageRef} className="crop-stage">
<img ref={imgRef} src={src} alt="Crop source" draggable={false} />
<div
className="crop-box"
style={{
transform: `translate(${crop.x}px, ${crop.y}px)`,
width: crop.width,
height: crop.height,
}}
onPointerDown={(e) => onPointerDown("move", e)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
tabIndex={0}
role="group"
aria-label="Crop region"
onKeyDown={(e) => {
const step = e.shiftKey ? 10 : 1;
const map: Record<string, Partial<Rect>> = {
ArrowLeft: { x: crop.x - step },
ArrowRight: { x: crop.x + step },
ArrowUp: { y: crop.y - step },
ArrowDown: { y: crop.y + step },
};
if (map[e.key]) {
e.preventDefault();
setCrop(clampCrop({ ...crop, ...map[e.key] }));
}
}}
>
<button
type="button"
aria-label="Resize crop"
className="handle se"
onPointerDown={(e) => {
e.stopPropagation();
onPointerDown("se", e);
}}
/>
</div>
</div>
);
}
Use transform for position to reduce layout thrash; width/height still layout.
Accessibility essentials
- Source image
altdescribing purpose (“Profile photo to crop”) - Crop box focusable; arrows nudge
- Resize handle named button
- Export button labeled “Download cropped image”
- Don’t make the only interaction pointer-drag
Performance notes
- Large images: draw export at natural size can be multi-megapixel — optional max output dimension
- Revoke object URLs
- Avoid re-decoding image each frame; only redraw canvas on export
Footguns
- Exporting display coordinates without multiplying by scale → wrong crop
- Ignoring devicePixelRatio when previewing on canvas (export still uses natural pixels)
- Drag leaves stage without pointer capture
- Aspect ratio breaking min size clamps
- CORS-tainted canvas when image is cross-origin without CORS headers —
crossOrigin = "anonymous"+ server ACAO
Interview out-loud answer
Crop UI is a positioned rectangle over a contain-fitted image. State is a display-space rect clamped to the image box; export multiplies by
naturalWidth/clientWidthanddrawImages into a canvas. MVP is move + export; corner resize and aspect lock next. I’d mention CORS for canvas export and keyboard nudging for a11y.
Related on this site
- Image Carousel
- Media CDN and Image Pipeline
- Upload Large Files UX
- Machine Coding Interview Framework