Crop box with handles, aspect presets, zoom and rotate, exported to a Blob.
01Preview
Arrow keys move the crop, Shift moves further, Option or Alt with arrows resizes. Plus and minus zoom, R rotates.
No cover yet
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { cropImage, ImageCropper } from "@/components/ui/image-cropper";
const [crop, setCrop] = useState(null);
<ImageCropper
src={file.url}
alt="Team offsite photo"
defaultAspect="16:9"
onCropComplete={(area, rotation) => setCrop({ area, rotation })}
/>
const blob = await cropImage(file.url, crop.area, { rotation: crop.rotation, maxWidth: 1600 });04Source
"use client";
import { Slider } from "@base-ui/react/slider";
import { Toggle } from "@base-ui/react/toggle";
import { ToggleGroup } from "@base-ui/react/toggle-group";
import { animate, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
/* ------------------------------------------------------------------ geometry */
/** A rectangle in the rotated image's own pixels. */
export type CropArea = { x: number; y: number; width: number; height: number };
export type CropRotation = 0 | 90 | 180 | 270;
export type CropAspect = { label: string; value: number | "free" };
type Rect = { x: number; y: number; w: number; h: number };
type View = { s: number; ox: number; oy: number };
type Handle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const PAD = 20; // air between the stage edge and the fitted image or crop
const MIN = 44; // smallest crop on screen, px
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** The largest rect of `ratio` centered in w × h (or 90% of it for a free crop). */
function initialCrop(w: number, h: number, ratio: number | "free"): Rect {
if (ratio === "free") return { x: w * 0.05, y: h * 0.05, w: w * 0.9, h: h * 0.9 };
let cw = w;
let ch = w / ratio;
if (ch > h) {
ch = h;
cw = h * ratio;
}
return { x: (w - cw) / 2, y: (h - ch) / 2, w: cw, h: ch };
}
/** Re-fit an existing crop to a new ratio around its center, staying inside the image. */
function refit(c: Rect, W: number, H: number, ratio: number | "free"): Rect {
if (ratio === "free") return c;
const cx = c.x + c.w / 2;
const cy = c.y + c.h / 2;
const area = c.w * c.h;
let w = Math.sqrt(area * ratio);
let h = w / ratio;
const scale = Math.min(1, W / w, H / h);
w *= scale;
h *= scale;
return { x: clamp(cx - w / 2, 0, W - w), y: clamp(cy - h / 2, 0, H - h), w, h };
}
/** CSS transform that draws the natural image into rotated space, before the view scale. */
function orient(r: CropRotation, nw: number, nh: number) {
if (r === 90) return `translate(${nh}px, 0) rotate(90deg)`;
if (r === 180) return `translate(${nw}px, ${nh}px) rotate(180deg)`;
if (r === 270) return `translate(0, ${nw}px) rotate(270deg)`;
return "";
}
/**
* Draws the crop to a canvas and returns it as a Blob. `area` is in the rotated
* image's pixels, exactly what the cropper reports. Remote images need CORS
* (the cropper sets crossOrigin="anonymous").
*/
export async function cropImage(
source: string | HTMLImageElement,
area: CropArea,
{ rotation = 0, type = "image/jpeg", quality = 0.92, maxWidth }: { rotation?: CropRotation; type?: string; quality?: number; maxWidth?: number } = {},
): Promise<Blob> {
const img =
typeof source === "string"
? await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.crossOrigin = "anonymous";
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Couldn’t load the image"));
i.src = source;
})
: source;
const scale = maxWidth && area.width > maxWidth ? maxWidth / area.width : 1;
const canvas = document.createElement("canvas");
canvas.width = Math.round(area.width * scale);
canvas.height = Math.round(area.height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas isn’t available");
ctx.imageSmoothingQuality = "high";
ctx.scale(scale, scale);
ctx.translate(-area.x, -area.y);
const nw = img.naturalWidth;
const nh = img.naturalHeight;
// Same mapping as the on-screen transform: natural pixels into rotated space.
if (rotation === 90) ctx.transform(0, 1, -1, 0, nh, 0);
else if (rotation === 180) ctx.transform(-1, 0, 0, -1, nw, nh);
else if (rotation === 270) ctx.transform(0, -1, 1, 0, 0, nw);
ctx.drawImage(img, 0, 0);
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Couldn’t export the crop"))), type, quality));
}
/* ------------------------------------------------------------------ component */
export const defaultAspects: CropAspect[] = [
{ label: "Free", value: "free" },
{ label: "1:1", value: 1 },
{ label: "4:5", value: 4 / 5 },
{ label: "16:9", value: 16 / 9 },
];
export type ImageCropperProps = Omit<React.ComponentProps<"div">, "children" | "onChange"> & {
src: string;
/** Describes the image for assistive tech. */
alt?: string;
aspects?: CropAspect[];
/** The chosen preset's label. */
aspect?: string;
defaultAspect?: string;
onAspectChange?: (label: string) => void;
maxZoom?: number;
/** Round mask for avatars. The area reported is still the square around it. */
shape?: "rect" | "round";
/** Stage height in px. The width follows the container. */
height?: number;
/** Called when a gesture ends (release, key up, preset, rotate) with the crop in rotated-image pixels. */
onCropComplete?: (area: CropArea, rotation: CropRotation) => void;
};
export function ImageCropper({
src,
alt = "",
aspects = defaultAspects,
aspect: aspectProp,
defaultAspect,
onAspectChange,
maxZoom = 4,
shape = "rect",
height = 320,
onCropComplete,
className,
...rest
}: ImageCropperProps) {
const reduce = !!useReducedMotion();
const hintId = useId();
const [aspectLabel, setAspectLabel] = useControllableState({
value: aspectProp,
defaultValue: defaultAspect ?? (shape === "round" ? (aspects.find((a) => a.value === 1)?.label ?? aspects[0].label) : aspects[0].label),
onChange: onAspectChange,
});
const ratio = shape === "round" ? 1 : (aspects.find((a) => a.label === aspectLabel)?.value ?? "free");
const stageRef = useRef<HTMLDivElement>(null);
const flipRef = useRef<HTMLDivElement>(null);
const [stage, setStage] = useState({ w: 0, h: height });
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [rotation, setRotation] = useState<CropRotation>(0);
const [crop, setCrop] = useState<Rect | null>(null);
const [view, setView] = useState<View>({ s: 1, ox: 0, oy: 0 });
const [gesture, setGesture] = useState<null | "move" | "resize" | "pan">(null);
const [activeHandle, setActiveHandle] = useState<Handle | null>(null);
const [announce, setAnnounce] = useState("");
const drag = useRef<{ kind: "move" | "resize" | "pan"; handle?: Handle; px: number; py: number; crop: Rect; view: View } | null>(null);
const settleTimer = useRef<number>(undefined);
const tween = useRef<{ stop: () => void } | null>(null);
const keyTimer = useRef<number>(undefined);
// Rotated image size.
const W = natural ? (rotation % 180 ? natural.h : natural.w) : 0;
const H = natural ? (rotation % 180 ? natural.w : natural.h) : 0;
const fitScale = W && stage.w ? Math.min((stage.w - PAD * 2) / W, (stage.h - PAD * 2) / H) : 1;
const zoom = view.s / fitScale;
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setStage({ w: e.contentRect.width, h: e.contentRect.height }));
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(
() => () => {
window.clearTimeout(settleTimer.current);
window.clearTimeout(keyTimer.current);
tween.current?.stop();
},
[],
);
/** A view that shows the whole rotated image, centered. */
const fitView = useCallback((w: number, h: number): View => {
const s = Math.min((stage.w - PAD * 2) / w, (stage.h - PAD * 2) / h);
return { s, ox: (stage.w - w * s) / 2, oy: (stage.h - h * s) / 2 };
}, [stage.w, stage.h]);
const report = useCallback(
(c: Rect | null, r: CropRotation) => {
if (!c) return;
onCropComplete?.({ x: Math.round(c.x), y: Math.round(c.y), width: Math.round(c.w), height: Math.round(c.h) }, r);
},
[onCropComplete],
);
// First layout, and again whenever the stage resizes: fit the image, keep the crop.
const laidOut = useRef("");
useEffect(() => {
if (!natural || !stage.w) return;
const key = `${stage.w}x${stage.h}`;
if (laidOut.current === key) return;
laidOut.current = key;
const t = window.setTimeout(() => {
setView(fitView(W, H));
if (!crop) {
const c = initialCrop(W, H, ratio);
setCrop(c);
report(c, rotation);
}
}, 0);
return () => window.clearTimeout(t);
}, [natural, stage.w, stage.h, W, H, ratio, fitView, crop, report, rotation]);
/** Tween the view (zoom and pan) on a spring; instant under reduced motion. */
const glide = useCallback(
(to: View) => {
tween.current?.stop();
if (reduce) return setView(to);
const from = view;
tween.current = animate(0, 1, {
...spring.soft,
onUpdate: (t) => setView({ s: from.s + (to.s - from.s) * t, ox: from.ox + (to.ox - from.ox) * t, oy: from.oy + (to.oy - from.oy) * t }),
});
},
[reduce, view],
);
/** Keep the crop on screen: pan the view the least amount needed. */
const keepVisible = (v: View, c: Rect): View => {
const left = v.ox + c.x * v.s;
const top = v.oy + c.y * v.s;
const right = left + c.w * v.s;
const bottom = top + c.h * v.s;
let dx = 0;
let dy = 0;
if (right - left > stage.w - PAD * 2) dx = stage.w / 2 - (left + right) / 2;
else if (left < PAD) dx = PAD - left;
else if (right > stage.w - PAD) dx = stage.w - PAD - right;
if (bottom - top > stage.h - PAD * 2) dy = stage.h / 2 - (top + bottom) / 2;
else if (top < PAD) dy = PAD - top;
else if (bottom > stage.h - PAD) dy = stage.h - PAD - bottom;
return { ...v, ox: v.ox + dx, oy: v.oy + dy };
};
/** Zoom around a screen point, never so far that the crop can't fit on the stage. */
const zoomTo = (z: number, around?: { x: number; y: number }) => {
if (!crop) return;
const maxByCrop = Math.min((stage.w - PAD * 2) / crop.w, (stage.h - PAD * 2) / crop.h);
const s = clamp(z * fitScale, fitScale, Math.min(maxZoom * fitScale, maxByCrop));
const p = around ?? { x: view.ox + (crop.x + crop.w / 2) * view.s, y: view.oy + (crop.y + crop.h / 2) * view.s };
const ix = (p.x - view.ox) / view.s;
const iy = (p.y - view.oy) / view.s;
tween.current?.stop();
setView(keepVisible({ s, ox: p.x - ix * s, oy: p.y - iy * s }, crop));
};
const zoomCap = crop && stage.w ? Math.max(1, Math.min(maxZoom, Math.min((stage.w - PAD * 2) / crop.w, (stage.h - PAD * 2) / crop.h) / fitScale)) : maxZoom;
/* ---------------------------------------------------------------- aspect, rotate, reset */
const chooseAspect = (label: string) => {
const next = aspects.find((a) => a.label === label);
if (!next || !crop) return;
setAspectLabel(label);
const c = refit(crop, W, H, next.value);
setCrop(c);
glide(fitView(W, H));
report(c, rotation);
};
const rotate = () => {
if (!natural || !crop) return;
// Counter-clockwise, like every photo tool: the crop rotates with the picture.
const next = (((rotation - 90) % 360) + 360) % 360 as CropRotation;
const nW = H;
const nH = W;
let c: Rect = { x: crop.y, y: W - (crop.x + crop.w), w: crop.h, h: crop.w };
if (ratio !== "free") c = refit(c, nW, nH, ratio);
const to = fitView(nW, nH);
const k = view.s / to.s;
tween.current?.stop();
setRotation(next);
setCrop(c);
setView(to);
report(c, next);
setAnnounce(`Rotated to ${next} degrees`);
// FLIP: start the new layout turned back 90° at the old size, then let it settle.
const el = flipRef.current;
if (el && !reduce) animate(el, { rotate: [90, 0], scale: [k, 1] }, spring.soft);
};
const reset = () => {
if (!natural) return;
const c = initialCrop(natural.w, natural.h, ratio);
setRotation(0);
setCrop(c);
glide(fitView(natural.w, natural.h));
report(c, 0);
};
const dirty = !!crop && !!natural && (rotation !== 0 || Math.abs(zoom - 1) > 0.01 || JSON.stringify(roundRect(crop)) !== JSON.stringify(roundRect(initialCrop(W, H, ratio))));
/* ---------------------------------------------------------------- pointer */
const onDown = (e: React.PointerEvent, kind: "move" | "resize" | "pan", handle?: Handle) => {
if (!crop || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
window.clearTimeout(settleTimer.current);
tween.current?.stop();
drag.current = { kind, handle, px: e.clientX, py: e.clientY, crop, view };
setGesture(kind);
setActiveHandle(handle ?? null);
};
const onMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = (e.clientX - d.px) / d.view.s;
const dy = (e.clientY - d.py) / d.view.s;
if (d.kind === "pan") {
setView(keepVisible({ ...d.view, ox: d.view.ox + (e.clientX - d.px), oy: d.view.oy + (e.clientY - d.py) }, d.crop));
return;
}
if (d.kind === "move") {
setCrop({ ...d.crop, x: clamp(d.crop.x + dx, 0, W - d.crop.w), y: clamp(d.crop.y + dy, 0, H - d.crop.h) });
return;
}
setCrop(resize(d.crop, d.handle!, dx, dy, W, H, ratio, MIN / d.view.s));
};
const onUp = () => {
const d = drag.current;
if (!d) return;
drag.current = null;
setGesture(null);
setActiveHandle(null);
report(crop, rotation);
if (d.kind === "resize" && crop) {
// After a resize settles, bring the crop back to the middle at a comfortable size.
const c = crop;
settleTimer.current = window.setTimeout(() => {
const s = clamp(Math.min((stage.w - PAD * 4) / c.w, (stage.h - PAD * 4) / c.h), fitScale, fitScale * maxZoom);
glide({ s, ox: stage.w / 2 - (c.x + c.w / 2) * s, oy: stage.h / 2 - (c.y + c.h / 2) * s });
}, 450);
}
};
/* ---------------------------------------------------------------- keyboard */
const onKey = (e: React.KeyboardEvent) => {
if (!crop) return;
const step = (e.shiftKey ? 10 : 1) / view.s; // 1 or 10 screen pixels
const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
let c: Rect | null = null;
if (dir && e.altKey) c = resize(crop, "se", dir[0] * step, dir[1] * step, W, H, ratio, MIN / view.s);
else if (dir) c = { ...crop, x: clamp(crop.x + dir[0] * step, 0, W - crop.w), y: clamp(crop.y + dir[1] * step, 0, H - crop.h) };
else if (e.key === "+" || e.key === "=") return (e.preventDefault(), zoomTo(zoom * 1.2));
else if (e.key === "-") return (e.preventDefault(), zoomTo(zoom / 1.2));
else if (e.key.toLowerCase() === "r" && !e.metaKey && !e.ctrlKey) return (e.preventDefault(), rotate());
if (!c) return;
e.preventDefault();
e.stopPropagation();
setCrop(c);
setView(keepVisible(view, c));
const done = c;
window.clearTimeout(keyTimer.current);
keyTimer.current = window.setTimeout(() => {
report(done, rotation);
setAnnounce(`Crop ${Math.round(done.w)} by ${Math.round(done.h)} pixels, at ${Math.round(done.x)}, ${Math.round(done.y)}`);
}, 400);
};
/* ---------------------------------------------------------------- render */
const box = crop ? { left: view.ox + crop.x * view.s, top: view.oy + crop.y * view.s, width: crop.w * view.s, height: crop.h * view.s } : null;
const handles: Handle[] = ratio === "free" ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "se", "sw"];
const size = crop ? `${Math.round(crop.w)} × ${Math.round(crop.h)}` : "";
const aspectOptions = shape === "round" ? [] : aspects;
return (
<div data-state={status} className={cn("flex w-full min-w-0 flex-col gap-2.5", className)} {...rest}>
{/* The stage is dark in both themes: judging a crop needs the picture, not the page, to be brightest. */}
<div
ref={stageRef}
data-theme="dark"
data-gesture={gesture ?? undefined}
className="relative w-full touch-none select-none overflow-hidden rounded-xl bg-page ring-1 ring-line"
style={{ height }}
onPointerDown={(e) => onDown(e, "pan")}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onWheel={(e) => {
if (!crop || !(e.ctrlKey || e.metaKey)) return;
// Pinch on a trackpad arrives as ctrl+wheel: zoom around the fingers.
const r = e.currentTarget.getBoundingClientRect();
zoomTo(zoom * Math.exp(-e.deltaY * 0.01), { x: e.clientX - r.left, y: e.clientY - r.top });
}}
>
<div ref={flipRef} className={cn("absolute inset-0 transition-opacity duration-200", status === "ready" && crop ? "opacity-100" : "opacity-0")}>
{/* eslint-disable-next-line @next/next/no-img-element -- the natural size and CORS pixels are needed for the crop */}
<img
src={src}
alt={alt}
crossOrigin="anonymous"
draggable={false}
ref={(i) => {
// A cached image can finish before hydration, and then onLoad never fires.
if (i?.complete && !natural) {
if (i.naturalWidth) {
setNatural({ w: i.naturalWidth, h: i.naturalHeight });
setStatus("ready");
} else setStatus("error");
}
}}
onLoad={(e) => {
const i = e.currentTarget;
setNatural({ w: i.naturalWidth, h: i.naturalHeight });
setStatus("ready");
}}
onError={() => setStatus("error")}
className="pointer-events-none absolute left-0 top-0 max-w-none origin-top-left"
style={natural ? { width: natural.w, height: natural.h, transform: `translate(${view.ox}px, ${view.oy}px) scale(${view.s}) ${orient(rotation, natural.w, natural.h)}` } : { opacity: 0 }}
/>
{box && (
<div
role="group"
tabIndex={0}
aria-roledescription="crop area"
aria-label={`Crop area, ${size} pixels`}
aria-describedby={hintId}
onKeyDown={onKey}
onPointerDown={(e) => onDown(e, "move")}
data-dragging={gesture === "move" || gesture === "resize" ? "" : undefined}
className={cn(
"group/crop absolute cursor-move outline-none",
// Everything outside the crop is dimmed by one huge spread shadow.
"shadow-[0_0_0_9999px_var(--overlay)] transition-[box-shadow] duration-200",
"focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-fg-2",
shape === "round" ? "rounded-full" : "ring-1 ring-fg/80",
)}
style={box}
>
{shape === "round" && <span aria-hidden className="pointer-events-none absolute inset-0 rounded-full ring-1 ring-fg/80" />}
{/* Thirds, only while you're adjusting: a guide for the gesture, not decoration at rest. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300 group-data-dragging/crop:opacity-100 group-data-dragging/crop:duration-100",
shape === "round" && "rounded-full",
"opacity-0",
)}
>
<span className="absolute inset-y-0 left-1/3 w-px bg-fg/40" />
<span className="absolute inset-y-0 left-2/3 w-px bg-fg/40" />
<span className="absolute inset-x-0 top-1/3 h-px bg-fg/40" />
<span className="absolute inset-x-0 top-2/3 h-px bg-fg/40" />
</span>
{handles.map((h) => (
<HandleGrip key={h} handle={h} active={activeHandle === h} onPointerDown={(e) => onDown(e, "resize", h)} />
))}
{/* Live size while adjusting. */}
<span
aria-hidden
className={cn(
"tabular pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-page/80 px-1.5 py-0.5 font-mono text-[11px] text-fg",
"transition-opacity duration-150",
gesture === "resize" ? "opacity-100" : "opacity-0",
)}
>
{size}
</span>
</div>
)}
</div>
{status === "loading" && (
<div aria-hidden className="absolute inset-5 grid place-items-center rounded-lg bg-hover animate-pulse-soft motion-reduce:animate-none">
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" className="text-fg-4">
<rect x="2.5" y="3" width="11" height="10" rx="1.75" />
<circle cx="6" cy="6.5" r="1.1" />
<path d="m2.75 11.5 3.4-3 2.4 2 1.6-1.4 3.1 2.6" />
</svg>
</div>
)}
{status === "error" && (
<div role="alert" className="absolute inset-0 grid place-items-center p-6 text-center">
<div className="flex flex-col items-center gap-1">
<p className="text-[13px] font-medium text-fg">Couldn’t load this image</p>
<p className="text-[12px] text-fg-3">Check the file or your connection, then choose it again.</p>
</div>
</div>
)}
</div>
<p id={hintId} className="sr-only">
Arrow keys move the crop, Shift moves further, Option or Alt with arrows resizes. Plus and minus zoom, R rotates.
</p>
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{aspectOptions.length > 1 && (
<AspectPicker options={aspectOptions} value={aspectLabel} onChange={chooseAspect} disabled={status !== "ready"} reduce={reduce} />
)}
<div className="ml-auto flex min-w-0 items-center gap-1">
<ToolButton label="Zoom out" onClick={() => zoomTo(zoom / 1.25)} disabled={status !== "ready" || zoom <= 1.001}>
<path d="M4 8h8" />
</ToolButton>
<Slider.Root
value={Math.min(zoom, zoomCap)}
min={1}
max={Math.max(zoomCap, 1.0001)}
step={0.01}
disabled={status !== "ready" || zoomCap <= 1.001}
onValueChange={(v) => zoomTo(v)}
className="w-24 sm:w-28"
>
<Slider.Control className="flex h-8 w-full touch-none items-center data-disabled:opacity-50">
<Slider.Track className="relative h-[3px] w-full rounded-full bg-fg/15">
<Slider.Indicator className="rounded-full bg-fg" />
<Slider.Thumb
aria-label="Zoom"
getAriaValueText={(_, v) => `${Math.round(v * 100)}%`}
className={cn(
"size-3.5 rounded-full border border-line-2 bg-raised shadow-[var(--shadow)] transition-[scale] duration-150 ease-out-quart data-dragging:scale-110 motion-reduce:transition-none",
"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3",
)}
/>
</Slider.Track>
</Slider.Control>
</Slider.Root>
<ToolButton label="Zoom in" onClick={() => zoomTo(zoom * 1.25)} disabled={status !== "ready" || zoom >= zoomCap - 0.001}>
<path d="M4 8h8M8 4v8" />
</ToolButton>
<span aria-hidden className="mx-1 h-4 w-px bg-line-2" />
<ToolButton label="Rotate left" shortcut="R" onClick={rotate} disabled={status !== "ready"}>
<path d="M3.5 6.5h6.25a2.75 2.75 0 0 1 2.75 2.75v3.25" />
<path d="M5.75 4.25 3.5 6.5l2.25 2.25" />
</ToolButton>
<ToolButton label="Reset crop" onClick={reset} disabled={!dirty}>
<path d="M3 8a5 5 0 1 0 1.5-3.55M3 2.75V5h2.25" />
</ToolButton>
</div>
</div>
</div>
);
}
const roundRect = (r: Rect) => [Math.round(r.x), Math.round(r.y), Math.round(r.w), Math.round(r.h)];
/** Drag a handle: the opposite edge or corner stays put; a locked ratio is kept from the longer pull. */
function resize(c: Rect, h: Handle, dx: number, dy: number, W: number, H: number, ratio: number | "free", min: number): Rect {
let { x, y, w, h: ht } = c;
const east = h.includes("e");
const west = h.includes("w");
const north = h.includes("n");
const south = h.includes("s");
if (ratio === "free") {
if (east) w = clamp(c.w + dx, min, W - c.x);
if (west) {
const nx = clamp(c.x + dx, 0, c.x + c.w - min);
w = c.w + (c.x - nx);
x = nx;
}
if (south) ht = clamp(c.h + dy, min, H - c.y);
if (north) {
const ny = clamp(c.y + dy, 0, c.y + c.h - min);
ht = c.h + (c.y - ny);
y = ny;
}
return { x, y, w, h: ht };
}
const ax = east ? c.x : c.x + c.w; // anchor
const ay = south ? c.y : c.y + c.h;
const pullW = c.w + (east ? dx : -dx);
const pullH = c.h + (south ? dy : -dy);
let nw = Math.max(pullW, pullH * ratio);
const maxW = Math.min(east ? W - ax : ax, (south ? H - ay : ay) * ratio);
nw = clamp(nw, Math.max(min, min * ratio), maxW);
const nh = nw / ratio;
return { x: east ? ax : ax - nw, y: south ? ay : ay - nh, w: nw, h: nh };
}
const CURSOR: Record<Handle, string> = { n: "cursor-ns-resize", s: "cursor-ns-resize", e: "cursor-ew-resize", w: "cursor-ew-resize", ne: "cursor-nesw-resize", sw: "cursor-nesw-resize", nw: "cursor-nwse-resize", se: "cursor-nwse-resize" };
function HandleGrip({ handle, active, onPointerDown }: { handle: Handle; active: boolean; onPointerDown: (e: React.PointerEvent) => void }) {
const corner = handle.length === 2;
const pos: Record<Handle, string> = {
nw: "left-0 top-0 -translate-x-1/2 -translate-y-1/2",
ne: "right-0 top-0 translate-x-1/2 -translate-y-1/2",
se: "right-0 bottom-0 translate-x-1/2 translate-y-1/2",
sw: "left-0 bottom-0 -translate-x-1/2 translate-y-1/2",
n: "left-1/2 top-0 -translate-x-1/2 -translate-y-1/2",
s: "left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2",
e: "right-0 top-1/2 translate-x-1/2 -translate-y-1/2",
w: "left-0 top-1/2 -translate-x-1/2 -translate-y-1/2",
};
// Corners are L-shaped brackets that hug the frame; edges are short bars.
// Each bracket starts on the handle's center (the crop corner) and hugs the frame line.
const bracket: Record<string, string> = {
nw: "left-[calc(50%-1.5px)] top-[calc(50%-1.5px)] border-l-[3px] border-t-[3px] rounded-tl-[3px]",
ne: "right-[calc(50%-1.5px)] top-[calc(50%-1.5px)] border-r-[3px] border-t-[3px] rounded-tr-[3px]",
se: "right-[calc(50%-1.5px)] bottom-[calc(50%-1.5px)] border-r-[3px] border-b-[3px] rounded-br-[3px]",
sw: "left-[calc(50%-1.5px)] bottom-[calc(50%-1.5px)] border-l-[3px] border-b-[3px] rounded-bl-[3px]",
};
return (
<span
aria-hidden
data-active={active ? "" : undefined}
onPointerDown={onPointerDown}
className={cn("group/handle absolute z-10 size-6 pointer-coarse:size-11", pos[handle], CURSOR[handle])}
>
{corner ? (
<span
className={cn(
"absolute size-4 border-fg transition-[scale] duration-150 ease-out-quart group-hover/handle:scale-110 group-data-active/handle:scale-125 motion-reduce:transition-none",
bracket[handle],
)}
// Grow from the corner itself, so the bracket stretches along the frame rather than drifting.
style={{ transformOrigin: `${handle.includes("w") ? "left" : "right"} ${handle.includes("n") ? "top" : "bottom"}` }}
/>
) : (
<span
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-fg transition-[scale] duration-150 ease-out-quart group-hover/handle:scale-110 group-data-active/handle:scale-125 motion-reduce:transition-none",
handle === "n" || handle === "s" ? "h-[3px] w-4" : "h-4 w-[3px]",
)}
/>
)}
</span>
);
}
function AspectPicker({ options, value, onChange, disabled, reduce }: { options: CropAspect[]; value: string; onChange: (v: string) => void; disabled: boolean; reduce: boolean }) {
const layoutId = useId();
return (
<ToggleGroup
aria-label="Aspect ratio"
value={[value]}
onValueChange={(v) => {
if (v[0]) onChange(v[0]);
}}
disabled={disabled}
className="relative flex h-8 items-center rounded-lg border border-line bg-raised p-0.5 shadow-[var(--shadow)] data-disabled:opacity-50"
>
{options.map((o) => (
<Toggle
key={o.label}
value={o.label}
className={cn(
"relative h-full rounded-md px-2.5 font-mono text-[11.5px] text-fg-3 outline-none",
"transition-[color,scale] duration-150 ease-out-quart hover:text-fg active:scale-[0.96] data-pressed:text-fg",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
)}
>
{o.label === value && (
// One pill slides between presets.
<motion.span layoutId={layoutId} aria-hidden className="absolute inset-0 rounded-md bg-hover ring-1 ring-line-2" transition={reduce ? { duration: 0 } : spring.snappy} />
)}
<span className="relative">{o.label}</span>
</Toggle>
))}
</ToggleGroup>
);
}
function ToolButton({ label, shortcut, onClick, disabled, children }: { label: string; shortcut?: string; onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
aria-keyshortcuts={shortcut}
title={shortcut ? `${label} (${shortcut})` : label}
onClick={onClick}
disabled={disabled}
className={cn(
"relative grid size-8 shrink-0 place-items-center rounded-lg text-fg-2 outline-none",
"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-40",
"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden",
)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{children}
</svg>
</button>
);
}05Props
ImageCropper
| Prop | Type | Default | Description |
|---|---|---|---|
| src* | string | — | The image. Remote files need CORS for the export (crossOrigin is set to anonymous). |
| alt | string | "" | Describes the image. |
| aspects | { label: string; value: number | "free" }[] | Free, 1:1, 4:5, 16:9 | Presets in the segmented control. |
| aspect | string | — | Controlled preset label. |
| defaultAspect | string | first preset | Uncontrolled starting preset. |
| onAspectChange | (label: string) => void | — | Called when a preset is chosen. |
| maxZoom | number | 4 | Largest magnification over the fitted image. |
| shape | "rect" | "round" | "rect" | Round masks the crop for avatars (square area, locked 1:1, no presets). |
| height | number | 320 | Stage height in px; the width follows the container. |
| onCropComplete | (area: CropArea, rotation: 0 | 90 | 180 | 270) => void | — | Fires once on load and after every gesture, preset, rotate or key burst, in rotated-image pixels. |
cropImage
| Prop | Type | Default | Description |
|---|---|---|---|
| source* | string | HTMLImageElement | — | The same image the cropper showed. |
| area* | CropArea | — | From onCropComplete. |
| options | { rotation?, type?, quality?, maxWidth? } | { type: "image/jpeg", quality: 0.92 } | Returns a Blob drawn on a canvas; maxWidth scales large crops down. |
06Notes
Behavior
- Drag inside the box to move it, a handle to resize it (corners only when a ratio is locked, all eight when free), or the picture outside it to pan. The crop can never leave the image.
- Half a second after a resize, the view glides to put the crop back in the middle at a comfortable size, so a small crop can be refined without hunting for the zoom.
- Zoom stops where the crop would no longer fit on screen; ctrl or pinch on the wheel zooms around the pointer. Changing preset keeps the crop's area and center.
- Rotating turns the crop with the picture. A cached image that loads before hydration is still picked up; a failed one says so in place.
Motion
- Rotate is a FLIP: the new layout starts turned back 90° at the old scale and settles on the soft spring, so the picture visibly turns rather than jumping.
- The thirds grid fades in over 100ms while you drag and out over 300ms after; corner brackets grow 25% from the corner while held.
- The preset pill slides between options on the snappy spring. Reduced motion drops the turn, the glide and the slide; changes land instantly.
Accessibility
- The crop box is focusable ("crop area"): arrows move it by 1px (Shift for 10), Alt with arrows resizes, plus and minus zoom, R rotates; the new size is announced after a key burst.
- Presets are a Base UI toggle group, zoom a Base UI slider with a percentage value, and every tool button has a label.