ESC

Type to search the knowledge base.

Image Cropper Lite

Machine-coding brief for a lite image cropper — crop box, drag/resize, aspect lock, canvas export, and state.

intermediate5 min read
  • 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 File or 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)

  1. Two coordinate spaces — CSS pixels on screen vs naturalWidth/Height
  2. Scale factor — naturalWidth / displayedWidth
  3. Crop state — { x, y, width, height } in display pixels, convert on export
  4. MVP — fixed-size box drag + export; then resize + aspect lock
  5. File read via URL.createObjectURL with 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 alt describing 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

  1. Exporting display coordinates without multiplying by scale → wrong crop
  2. Ignoring devicePixelRatio when previewing on canvas (export still uses natural pixels)
  3. Drag leaves stage without pointer capture
  4. Aspect ratio breaking min size clamps
  5. 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/clientWidth and drawImages 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.

Further reading