Image Cropper
Pan-and-zoom image cropper with rect or round mask, fixed aspect frame, pinch / wheel / keyboard control and canvas export via ref.
Example
Installation
npx shadcn@latest add https://hirael.com/r/image-cropper.jsonAPI
<ImageCropper />
+ native element props| Prop | Type | Default |
|---|---|---|
src* | string | — |
alt | string | "" |
aspect | number | 1 |
shape | "rect" | "round" | "rect" |
zoom | number | — |
defaultZoom | number | 1 |
onZoomChange | ((zoom: number) => void) | — |
maxZoom | number | 3 |
crop | ImageCropperCrop | — |
defaultCrop | ImageCropperCrop | — |
onCropChange | ((crop: ImageCropperCrop) => void) | — |
grid | boolean | false |
disabled | boolean | — |
crossOrigin | "" | "anonymous" | "use-credentials" | "anonymous" |
ref | React.Ref<ImageCropperRef> | — |
<ImageCropperZoom />
+ native element propsNo props of its own — forwards everything to the underlying element.
Component source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Slider } from "@/registry/hirael/ui/slider";
export type ImageCropperCrop = { x: number; y: number };
export type ImageCropperRef = {
getCroppedDataUrl: (opts?: {
size?: number;
type?: string;
quality?: number;
}) => string | null;
reset: () => void;
};
type Size = { w: number; h: number };
type ImageCropperContextValue = {
zoom: number;
setZoom: (next: number) => void;
minZoom: number;
maxZoom: number;
disabled?: boolean;
};
const ImageCropperContext =
React.createContext<ImageCropperContextValue | null>(null);
function useImageCropper() {
const ctx = React.useContext(ImageCropperContext);
if (!ctx) {
throw new Error(
"ImageCropper compound parts must be used inside <ImageCropper>",
);
}
return ctx;
}
function clamp(n: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, n));
}
function coverScale(frame: Size, natural: Size) {
return Math.max(frame.w / natural.w, frame.h / natural.h);
}
function clampCrop(
crop: ImageCropperCrop,
zoom: number,
frame: Size | null,
natural: Size | null,
): ImageCropperCrop {
if (!frame || !natural || frame.w === 0 || frame.h === 0) return crop;
const scale = coverScale(frame, natural) * zoom;
const maxX = Math.max(0, (natural.w * scale - frame.w) / 2);
const maxY = Math.max(0, (natural.h * scale - frame.h) / 2);
return { x: clamp(crop.x, -maxX, maxX), y: clamp(crop.y, -maxY, maxY) };
}
export type ImageCropperProps = Omit<
React.ComponentProps<"div">,
"ref" | "onWheel" | "onDoubleClick"
> & {
src: string;
alt?: string;
aspect?: number;
shape?: "rect" | "round";
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
maxZoom?: number;
crop?: ImageCropperCrop;
defaultCrop?: ImageCropperCrop;
onCropChange?: (crop: ImageCropperCrop) => void;
grid?: boolean;
disabled?: boolean;
crossOrigin?: "" | "anonymous" | "use-credentials";
ref?: React.Ref<ImageCropperRef>;
};
function ImageCropper({
src,
alt = "",
aspect = 1,
shape = "rect",
zoom: zoomProp,
defaultZoom = 1,
onZoomChange,
maxZoom = 3,
crop: cropProp,
defaultCrop,
onCropChange,
grid = false,
disabled,
crossOrigin = "anonymous",
ref,
className,
children,
...props
}: ImageCropperProps) {
const frameRef = React.useRef<HTMLDivElement | null>(null);
const imgRef = React.useRef<HTMLImageElement | null>(null);
const [naturalSize, setNaturalSize] = React.useState<Size | null>(null);
const [frameSize, setFrameSize] = React.useState<Size | null>(null);
const [dragging, setDragging] = React.useState(false);
const [internalZoom, setInternalZoom] = React.useState(() =>
clamp(defaultZoom, 1, maxZoom),
);
const zoom = zoomProp ?? internalZoom;
const [internalCrop, setInternalCrop] = React.useState<ImageCropperCrop>(
() => defaultCrop ?? { x: 0, y: 0 },
);
const crop = cropProp ?? internalCrop;
const setZoom = React.useCallback(
(next: number) => {
const clamped = clamp(next, 1, maxZoom);
if (zoomProp === undefined) setInternalZoom(clamped);
onZoomChange?.(clamped);
},
[zoomProp, onZoomChange, maxZoom],
);
const setCrop = React.useCallback(
(next: ImageCropperCrop) => {
if (cropProp === undefined) setInternalCrop(next);
onCropChange?.(next);
},
[cropProp, onCropChange],
);
const zoomRef = React.useRef(zoom);
zoomRef.current = zoom;
const cropRef = React.useRef(crop);
cropRef.current = crop;
const frameSizeRef = React.useRef(frameSize);
frameSizeRef.current = frameSize;
const naturalSizeRef = React.useRef(naturalSize);
naturalSizeRef.current = naturalSize;
const panBy = React.useCallback(
(dx: number, dy: number) => {
const c = cropRef.current;
setCrop(
clampCrop(
{ x: c.x + dx, y: c.y + dy },
zoomRef.current,
frameSizeRef.current,
naturalSizeRef.current,
),
);
},
[setCrop],
);
const zoomAt = React.useCallback(
(next: number, focal?: ImageCropperCrop) => {
const z0 = zoomRef.current;
const z1 = clamp(next, 1, maxZoom);
if (z1 === z0) return;
let c = cropRef.current;
if (focal) {
const ratio = z1 / z0;
c = {
x: focal.x - ratio * (focal.x - c.x),
y: focal.y - ratio * (focal.y - c.y),
};
}
setCrop(clampCrop(c, z1, frameSizeRef.current, naturalSizeRef.current));
setZoom(z1);
},
[maxZoom, setCrop, setZoom],
);
const setZoomCentered = React.useCallback(
(next: number) => zoomAt(next, { x: 0, y: 0 }),
[zoomAt],
);
const reset = React.useCallback(() => {
const z = clamp(defaultZoom, 1, maxZoom);
setZoom(z);
setCrop(
clampCrop(
defaultCrop ?? { x: 0, y: 0 },
z,
frameSizeRef.current,
naturalSizeRef.current,
),
);
}, [defaultZoom, defaultCrop, maxZoom, setZoom, setCrop]);
React.useLayoutEffect(() => {
const img = imgRef.current;
if (img && img.complete && img.naturalWidth > 0) {
setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
}
}, [src]);
React.useEffect(() => {
const el = frameRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const rect = entries[0]?.contentRect;
if (rect) setFrameSize({ w: rect.width, h: rect.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
React.useEffect(() => {
const clamped = clampCrop(crop, zoom, frameSize, naturalSize);
if (clamped.x !== crop.x || clamped.y !== crop.y) setCrop(clamped);
}, [crop, zoom, frameSize, naturalSize, setCrop]);
const wheelHandlerRef = React.useRef<(e: WheelEvent) => void>(() => {});
wheelHandlerRef.current = (e: WheelEvent) => {
if (disabled) return;
e.preventDefault();
const el = frameRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const focal = {
x: e.clientX - rect.left - rect.width / 2,
y: e.clientY - rect.top - rect.height / 2,
};
const factor = Math.exp(-e.deltaY * 0.002);
zoomAt(zoomRef.current * factor, focal);
};
React.useEffect(() => {
const el = frameRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => wheelHandlerRef.current(e);
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const pointersRef = React.useRef(new Map<number, ImageCropperCrop>());
const pinchRef = React.useRef<{ dist: number; zoom: number } | null>(null);
const pinchDistance = () => {
const pts = Array.from(pointersRef.current.values());
if (pts.length < 2) return 0;
return Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
};
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointersRef.current.size === 2) {
pinchRef.current = { dist: pinchDistance(), zoom: zoomRef.current };
}
setDragging(true);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
const prev = pointersRef.current.get(e.pointerId);
if (!prev) return;
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointersRef.current.size === 1) {
panBy(e.clientX - prev.x, e.clientY - prev.y);
return;
}
const pinch = pinchRef.current;
const el = frameRef.current;
if (!pinch || !el || pinch.dist === 0) return;
const pts = Array.from(pointersRef.current.values());
const rect = el.getBoundingClientRect();
const focal = {
x: (pts[0].x + pts[1].x) / 2 - rect.left - rect.width / 2,
y: (pts[0].y + pts[1].y) / 2 - rect.top - rect.height / 2,
};
zoomAt(pinch.zoom * (pinchDistance() / pinch.dist), focal);
};
const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(e.pointerId);
if (pointersRef.current.size < 2) pinchRef.current = null;
if (pointersRef.current.size === 0) setDragging(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const step = e.shiftKey ? 20 : 5;
switch (e.key) {
case "ArrowLeft":
panBy(step, 0);
break;
case "ArrowRight":
panBy(-step, 0);
break;
case "ArrowUp":
panBy(0, step);
break;
case "ArrowDown":
panBy(0, -step);
break;
case "+":
case "=":
setZoomCentered(zoomRef.current + 0.1);
break;
case "-":
case "_":
setZoomCentered(zoomRef.current - 0.1);
break;
default:
return;
}
e.preventDefault();
};
React.useImperativeHandle(
ref,
() => ({
getCroppedDataUrl: (opts) => {
const img = imgRef.current;
const frame = frameSizeRef.current;
const natural = naturalSizeRef.current;
if (!img || !img.complete || !frame || !natural) return null;
const scale = coverScale(frame, natural) * zoomRef.current;
const sw = frame.w / scale;
const sh = frame.h / scale;
const sx = natural.w / 2 - cropRef.current.x / scale - sw / 2;
const sy = natural.h / 2 - cropRef.current.y / scale - sh / 2;
const outW = Math.max(1, Math.round(opts?.size ?? 512));
const outH = Math.max(1, Math.round(outW / aspect));
const canvas = document.createElement("canvas");
canvas.width = outW;
canvas.height = outH;
const ctx2d = canvas.getContext("2d");
if (!ctx2d) return null;
ctx2d.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH);
try {
return canvas.toDataURL(opts?.type ?? "image/png", opts?.quality);
} catch {
return null;
}
},
reset,
}),
[aspect, reset],
);
const ready = naturalSize !== null && frameSize !== null;
const baseW = ready ? naturalSize.w * coverScale(frameSize, naturalSize) : 0;
const baseH = ready ? naturalSize.h * coverScale(frameSize, naturalSize) : 0;
const ctx = React.useMemo<ImageCropperContextValue>(
() => ({
zoom,
setZoom: setZoomCentered,
minZoom: 1,
maxZoom,
disabled,
}),
[zoom, setZoomCentered, maxZoom, disabled],
);
return (
<ImageCropperContext.Provider value={ctx}>
<div
data-slot="image-cropper"
className={cn("grid w-full gap-3", className)}
{...props}
>
<div
ref={frameRef}
role="application"
aria-label={alt ? `Crop ${alt}` : "Image cropper"}
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
data-slot="image-cropper-frame"
data-shape={shape}
data-dragging={dragging || undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={() => {
if (!disabled) reset();
}}
onKeyDown={handleKeyDown}
style={{ aspectRatio: aspect }}
className={cn(
"relative w-full select-none touch-none overflow-hidden rounded-sm border border-border bg-muted outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
disabled
? "cursor-not-allowed opacity-60"
: dragging
? "cursor-grabbing"
: "cursor-grab",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={src}
alt={alt}
crossOrigin={crossOrigin}
draggable={false}
data-slot="image-cropper-image"
onLoad={(e) =>
setNaturalSize({
w: e.currentTarget.naturalWidth,
h: e.currentTarget.naturalHeight,
})
}
className={cn(
"pointer-events-none max-w-none",
ready ? "absolute left-1/2 top-1/2" : "size-full object-cover",
)}
style={
ready
? {
width: baseW,
height: baseH,
transform: `translate(calc(-50% + ${crop.x}px), calc(-50% + ${crop.y}px)) scale(${zoom})`,
}
: undefined
}
/>
{shape === "round" && (
<div
aria-hidden
data-slot="image-cropper-mask"
className="pointer-events-none absolute inset-0 rounded-full shadow-[0_0_0_9999px_rgba(0,0,0,0.55)] ring-1 ring-white/40 ring-inset"
/>
)}
{grid && dragging && (
<div
aria-hidden
data-slot="image-cropper-grid"
className="pointer-events-none absolute inset-0"
>
<div className="absolute inset-y-0 left-1/3 w-px bg-white/40" />
<div className="absolute inset-y-0 left-2/3 w-px bg-white/40" />
<div className="absolute inset-x-0 top-1/3 h-px bg-white/40" />
<div className="absolute inset-x-0 top-2/3 h-px bg-white/40" />
</div>
)}
</div>
{children}
</div>
</ImageCropperContext.Provider>
);
}
type ImageCropperZoomProps = Omit<
React.ComponentProps<typeof Slider>,
"value" | "defaultValue" | "min" | "max" | "onValueChange"
>;
function ImageCropperZoom({ className, ...props }: ImageCropperZoomProps) {
const ctx = useImageCropper();
return (
<Slider
aria-label="Zoom"
min={ctx.minZoom}
max={ctx.maxZoom}
step={0.01}
value={[ctx.zoom]}
onValueChange={([v]) => ctx.setZoom(v)}
disabled={ctx.disabled}
data-slot="image-cropper-zoom"
className={cn("w-full", className)}
{...props}
/>
);
}
export { ImageCropper, ImageCropperZoom };
Dependencies
shadcn registry
slider