Crop a picture under a fixed frame: drag to move, wheel, pinch or slider to zoom, straighten and turn by quarters, any aspect ratio or a round avatar, full keyboard control and a canvas export to a file.
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
The result appears here.
"use client"
import * as React from "react"
import { Button } from "@/components/ballmac/button"
import { ImageCropper, type ImageCropperHandle } from "@/components/ballmac/image-cropper"
/** A picture drawn in code, so the example needs no file: a sky, a sun, hills and a few houses, with enough detail to see what a crop keeps. */
const scene = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="rgb(255,183,120)"/><stop offset="0.55" stop-color="rgb(255,222,170)"/><stop offset="1" stop-color="rgb(190,215,255)"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#sky)"/><circle cx="1180" cy="330" r="130" fill="rgb(255,246,214)"/><path d="M0 680 Q300 480 620 640 T1240 600 T1600 660 V1000 H0Z" fill="rgb(112,148,120)"/><path d="M0 780 Q400 620 800 760 T1600 740 V1000 H0Z" fill="rgb(72,112,96)"/><g fill="rgb(245,235,220)"><rect x="300" y="700" width="120" height="90"/><rect x="470" y="730" width="90" height="70"/><rect x="1020" y="690" width="140" height="100"/></g><g fill="rgb(176,78,64)"><path d="M290 700 L360 640 L430 700Z"/><path d="M460 730 L515 685 L570 730Z"/><path d="M1010 690 L1090 620 L1170 690Z"/></g><g fill="rgb(40,60,52)"><rect x="880" y="690" width="14" height="70"/><circle cx="887" cy="672" r="42"/><rect x="1300" y="710" width="14" height="70"/><circle cx="1307" cy="692" r="38"/></g><text x="800" y="940" font-family="sans-serif" font-size="64" text-anchor="middle" fill="rgb(255,255,255)">GOLDEN HOUR</text></svg>`
)}`
export default function ImageCropperDemo() {
const cropper = React.useRef<ImageCropperHandle>(null)
const [result, setResult] = React.useState<string | null>(null)
React.useEffect(() => () => void (result && URL.revokeObjectURL(result)), [result])
const crop = async () => {
const blob = await cropper.current?.toBlob({ width: 480, type: "image/jpeg" })
if (blob) setResult(URL.createObjectURL(blob))
}
return (
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-[1fr_9rem] sm:items-start">
<ImageCropper cropperRef={cropper} src={scene} aspect={1} alt="Crop the sunset picture" height={300} />
<div className="grid gap-2">
<Button type="button" onClick={crop}>
Crop
</Button>
{result ? <img src={result} alt="The cropped picture" className="aspect-square w-full rounded-lg border object-cover" /> : <p className="text-xs text-muted-foreground">The result appears here.</p>}
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/image-cropperInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/skeleton @ballmac/i18nCopy the source into your project.
components/ballmac/image-cropper.tsx// Ballmac UI: Image Cropper. https://ui.ballmac.com/components/image-cropper "use client" import * as React from "react" import { ImageOff, RotateCcw, RotateCw, ZoomIn, ZoomOut } from "lucide-react" import { Button } from "@/components/ballmac/button" import { Skeleton } from "@/components/ballmac/skeleton" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" /** How the picture sits under the frame. `x` and `y` move its center from the frame's center, in pixels on screen. `zoom` is 1 at the smallest the picture may be. */ type Crop = { x: number; y: number; zoom: number; /** Degrees, any amount. */ rotation: number } /** The cropped part, in pixels of the original picture. With a rotation it is the box around the rotated frame. */ type CropArea = { x: number; y: number; width: number; height: number } type CropResult = { crop: Crop; area: CropArea; /** The crop frame's width and height on screen. */ frame: { width: number; height: number } } type CropImageOptions = { /** Width of the result in pixels. The height follows the frame. Defaults to the cropped area's own width, at most `maxWidth`. */ width?: number maxWidth?: number type?: "image/png" | "image/jpeg" | "image/webp" quality?: number /** Cut the corners away, for a round avatar. A PNG or WebP keeps them see-through; a JPEG fills them with `background`. */ round?: boolean background?: string } type ImageCropperHandle = { /** The crop as it is now. */ getCrop: () => CropResult | null /** The cropped picture as a file. */ toBlob: (options?: CropImageOptions) => Promise<Blob> reset: () => void } type ImageCropperProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue" | "ref"> & { /** The picture: a web address or an object URL. */ src: string /** Width divided by height of the frame: 1 for a square, 16 / 9 for a banner. */ aspect?: number /** A round frame shows what a round avatar will keep. */ shape?: "rect" | "round" value?: Crop defaultValue?: Partial<Crop> onCropChange?: (crop: Crop) => void /** Called when a drag, a key press or a slider move ends, with the crop in pixels of the original. */ onCropComplete?: (result: CropResult) => void /** Zoom the picture can reach. */ maxZoom?: number /** Height of the picture area. */ height?: number /** Show thirds lines over the frame. */ grid?: boolean /** Zoom with the mouse wheel. Off if the cropper sits in a page people scroll through. */ zoomOnWheel?: boolean /** Hide the zoom, straighten and rotate controls, to place your own. */ controls?: boolean /** Written for a screen reader: what the picture shows. */ alt?: string /** `crossorigin` for the picture. Needed to export one from another site. */ crossOrigin?: "anonymous" | "use-credentials" | "" onError?: (error: Error) => void /** Gives you `toBlob`, `getCrop` and `reset`. */ cropperRef?: React.Ref<ImageCropperHandle> } // ------------------------------------------------------------------------------------------------- geometry (pure, so it can be tested) const rad = (d: number) => (d * Math.PI) / 180 const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)) /** The smallest scale at which a picture of this size, turned by `rotation`, still covers the frame. */ export function coverScale(image: { width: number; height: number }, frame: { width: number; height: number }, rotation: number) { const t = rad(rotation) const cos = Math.abs(Math.cos(t)) const sin = Math.abs(Math.sin(t)) // the frame's corners, turned into the picture's own axes, must lie inside it const needW = (frame.width * cos + frame.height * sin) / image.width const needH = (frame.width * sin + frame.height * cos) / image.height return Math.max(needW, needH) } /** Keeps the picture over the frame: moves `offset` to the nearest place where no corner of the frame is outside it. */ export function clampOffset(offset: { x: number; y: number }, image: { width: number; height: number }, frame: { width: number; height: number }, scale: number, rotation: number) { const t = rad(rotation) const cos = Math.cos(t) const sin = Math.sin(t) const halfW = (image.width * scale) / 2 const halfH = (image.height * scale) / 2 let { x, y } = offset // In the picture's axes the frame is a turned box. Its centre may move until a corner touches the edge: check each of the four corners and // push back along the picture's axes. A few passes settle it, because pushing along one axis can free the other. const corners = [ [frame.width / 2, frame.height / 2], [-frame.width / 2, frame.height / 2], [frame.width / 2, -frame.height / 2], [-frame.width / 2, -frame.height / 2], ] as const for (let pass = 0; pass < 24; pass++) { let moved = false for (const [cx, cy] of corners) { // the corner in the picture's axes, relative to the picture's centre: x along (cos, sin), y along (-sin, cos) const rx = cx - x const ry = cy - y const px = rx * cos + ry * sin const py = -rx * sin + ry * cos const overX = Math.abs(px) - halfW const overY = Math.abs(py) - halfH if (overX > 1e-9) { const d = Math.sign(px) * overX x += d * cos y += d * sin moved = true } if (overY > 1e-9) { const d = Math.sign(py) * overY x -= d * sin y += d * cos moved = true } } if (!moved) break } return { x, y } } /** The part of the original picture inside the frame. */ export function cropArea(image: { width: number; height: number }, frame: { width: number; height: number }, crop: Crop, baseScale: number): CropArea { const scale = baseScale * crop.zoom const t = rad(crop.rotation) const cos = Math.abs(Math.cos(t)) const sin = Math.abs(Math.sin(t)) const boxW = (frame.width * cos + frame.height * sin) / scale const boxH = (frame.width * sin + frame.height * cos) / scale // the frame's centre in the picture's own pixels const c = Math.cos(rad(crop.rotation)) const s = Math.sin(rad(crop.rotation)) const px = (-crop.x * c - crop.y * s) / scale const py = (crop.x * s - crop.y * c) / scale const cx = image.width / 2 + px const cy = image.height / 2 + py return { x: Math.round(cx - boxW / 2), y: Math.round(cy - boxH / 2), width: Math.round(boxW), height: Math.round(boxH) } } /** The canvas transform that draws the cropped picture at `outWidth` pixels across: translate, rotate, scale, in that order. */ export function cropTransform(frame: { width: number; height: number }, crop: Crop, baseScale: number, outWidth: number) { const k = outWidth / frame.width return { outWidth, outHeight: Math.round(frame.height * k), translateX: (frame.width / 2 + crop.x) * k, translateY: (frame.height / 2 + crop.y) * k, rotation: rad(crop.rotation), scale: baseScale * crop.zoom * k } } /** Draws the crop to a canvas and returns it as a file. The picture must be same-origin or served with CORS headers. */ export async function cropImage(image: HTMLImageElement, frame: { width: number; height: number }, crop: Crop, baseScale: number, options: CropImageOptions = {}): Promise<Blob> { const { maxWidth = 2048, type = "image/png", quality = 0.92, round = false, background = "rgb(255, 255, 255)" } = options const natural = baseScale * crop.zoom const width = Math.round(clamp(options.width ?? frame.width / natural, 1, maxWidth)) const t = cropTransform(frame, crop, baseScale, width) const canvas = document.createElement("canvas") canvas.width = t.outWidth canvas.height = t.outHeight const ctx = canvas.getContext("2d") if (!ctx) throw new Error("A canvas is not available.") if (type === "image/jpeg") { ctx.fillStyle = background ctx.fillRect(0, 0, canvas.width, canvas.height) } if (round) { ctx.beginPath() ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2) ctx.clip() } ctx.imageSmoothingQuality = "high" ctx.translate(t.translateX, t.translateY) ctx.rotate(t.rotation) ctx.scale(t.scale, t.scale) ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2) return new Promise<Blob>((resolve, reject) => { try { canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("The picture could not be made."))), type, quality) } catch (e) { reject(e instanceof Error ? e : new Error(String(e))) } }) } // ------------------------------------------------------------------------------------------------- component const START: Crop = { x: 0, y: 0, zoom: 1, rotation: 0 } const STEP = 10 /** * Crops a picture with a frame that stays still while the picture moves under it: drag to move, wheel, pinch or slider to zoom, a slider to straighten * and buttons to turn it a quarter. The frame can be any shape from `aspect`, and `toBlob` gives you the result as a file. Every control has a key. */ function ImageCropper({ src, aspect = 1, shape = "rect", value, defaultValue, onCropChange, onCropComplete, maxZoom = 4, height = 320, grid = true, zoomOnWheel = true, controls = true, alt, crossOrigin = "anonymous", onError, className, cropperRef, ...props }: ImageCropperProps) { const msg = useMessages() const id = React.useId() const box = React.useRef<HTMLDivElement>(null) const img = React.useRef<HTMLImageElement>(null) const [status, setStatus] = React.useState<"loading" | "ready" | "error">("loading") const [natural, setNatural] = React.useState({ width: 0, height: 0 }) const [size, setSize] = React.useState({ width: 480, height }) const [inner, setInner] = React.useState<Crop>({ ...START, ...defaultValue }) const crop = value ?? inner const [dragging, setDragging] = React.useState(false) const [spoken, setSpoken] = React.useState("") const [attempt, setAttempt] = React.useState(0) const pointers = React.useRef(new Map<number, { x: number; y: number }>()) const pinch = React.useRef<{ distance: number; zoom: number } | null>(null) const onCompleteRef = React.useRef(onCropComplete) onCompleteRef.current = onCropComplete React.useEffect(() => { setStatus("loading") // A picture that finished loading before this page was hydrated has already fired its load event, so ask it. It is ready if it has a size, // and has failed if it finished with none. const el = img.current if (el?.complete && src) { if (el.naturalWidth > 0) { setNatural({ width: el.naturalWidth, height: el.naturalHeight }) setStatus("ready") } else setStatus("error") } }, [src, attempt]) // the box's width is whatever the page gives it React.useEffect(() => { const el = box.current if (!el || typeof ResizeObserver === "undefined") return const ro = new ResizeObserver(() => setSize({ width: el.clientWidth || 480, height: el.clientHeight || height })) ro.observe(el) setSize({ width: el.clientWidth || 480, height: el.clientHeight || height }) return () => ro.disconnect() }, [height]) // the frame: the biggest box of this shape that fits with some air around it const frame = React.useMemo(() => { const room = { width: Math.max(size.width - 32, 40), height: Math.max(size.height - 32, 40) } const w = Math.min(room.width, room.height * aspect) return { width: Math.round(w), height: Math.round(w / aspect) } }, [size, aspect]) const base = natural.width ? coverScale(natural, frame, crop.rotation) : 1 const scale = base * crop.zoom const set = React.useCallback( (next: Partial<Crop>, done = false) => { const zoom = clamp(next.zoom ?? crop.zoom, 1, maxZoom) const rotation = next.rotation ?? crop.rotation const b = natural.width ? coverScale(natural, frame, rotation) : 1 const kept = natural.width ? clampOffset({ x: next.x ?? crop.x, y: next.y ?? crop.y }, natural, frame, b * zoom, rotation) : { x: 0, y: 0 } const out: Crop = { x: kept.x, y: kept.y, zoom, rotation } if (value === undefined) setInner(out) onCropChange?.(out) if (done && natural.width) onCompleteRef.current?.({ crop: out, area: cropArea(natural, frame, out, b), frame }) }, [crop, frame, natural, maxZoom, value, onCropChange] ) // the frame or the picture changed size: keep the picture over it React.useEffect(() => { if (status === "ready") set({}, false) // eslint-disable-next-line react-hooks/exhaustive-deps }, [frame.width, frame.height, natural.width, status]) React.useImperativeHandle( cropperRef, () => ({ getCrop: () => (natural.width ? { crop, area: cropArea(natural, frame, crop, base), frame } : null), toBlob: (options) => { const el = img.current if (!el || !natural.width) return Promise.reject(new Error("The picture has not loaded yet.")) return cropImage(el, frame, crop, base, { round: shape === "round", ...options }) }, reset: () => set({ ...START }, true), }), [natural, crop, frame, base, shape, set] ) // ----- pointer: drag to move, two fingers to zoom const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { if (status !== "ready") return e.currentTarget.setPointerCapture(e.pointerId) pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }) setDragging(true) if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()] pinch.current = { distance: Math.hypot(a!.x - b!.x, a!.y - b!.y), zoom: crop.zoom } } } const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { const prev = pointers.current.get(e.pointerId) if (!prev) return const now = { x: e.clientX, y: e.clientY } pointers.current.set(e.pointerId, now) if (pointers.current.size === 2 && pinch.current) { const [a, b] = [...pointers.current.values()] const d = Math.hypot(a!.x - b!.x, a!.y - b!.y) set({ zoom: pinch.current.zoom * (d / pinch.current.distance) }) return } if (pointers.current.size === 1) set({ x: crop.x + (now.x - prev.x), y: crop.y + (now.y - prev.y) }) } const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => { if (!pointers.current.delete(e.pointerId)) return pinch.current = null if (pointers.current.size === 0) { setDragging(false) set({}, true) } } const wheel = React.useRef({ zoomOnWheel, set, crop }) wheel.current = { zoomOnWheel, set, crop } React.useEffect(() => { const el = box.current if (!el) return let t: ReturnType<typeof setTimeout> | undefined const onWheel = (e: WheelEvent) => { if (!wheel.current.zoomOnWheel || status !== "ready") return e.preventDefault() wheel.current.set({ zoom: wheel.current.crop.zoom * Math.exp(-e.deltaY * 0.0015) }) clearTimeout(t) t = setTimeout(() => wheel.current.set({}, true), 150) } el.addEventListener("wheel", onWheel, { passive: false }) return () => { el.removeEventListener("wheel", onWheel) clearTimeout(t) } }, [status]) // ----- keyboard const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { if (status !== "ready" || e.target !== e.currentTarget) return const step = e.shiftKey ? STEP * 4 : STEP let handled = true switch (e.key) { case "ArrowLeft": set({ x: crop.x + step }, true); break case "ArrowRight": set({ x: crop.x - step }, true); break case "ArrowUp": set({ y: crop.y + step }, true); break case "ArrowDown": set({ y: crop.y - step }, true); break case "+": case "=": zoomBy(1.1); break case "-": case "_": zoomBy(1 / 1.1); break case "[": turn(-90); break case "]": turn(90); break case "0": set({ ...START }, true); setSpoken(msg("image-cropper.resetDone", "Reset")); break default: handled = false } if (handled) e.preventDefault() } const zoomBy = (f: number) => { const zoom = clamp(crop.zoom * f, 1, maxZoom) set({ zoom }, true) setSpoken(msg("image-cropper.zoomSpoken", "Zoom {percent}%", { percent: Math.round(zoom * 100) })) } const turn = (deg: number) => { const rotation = crop.rotation + deg set({ rotation }, true) setSpoken(msg("image-cropper.rotationSpoken", "Rotated to {degrees} degrees", { degrees: ((Math.round(rotation) % 360) + 360) % 360 })) } // the straighten slider moves within a quarter turn either side of the nearest quarter const quarter = Math.round(crop.rotation / 90) * 90 const fine = clamp(crop.rotation - quarter, -45, 45) const showGrid = grid && status === "ready" const round = shape === "round" const transform = `translate(calc(-50% + ${crop.x}px), calc(-50% + ${crop.y}px)) rotate(${crop.rotation}deg) scale(${scale})` return ( <div data-slot="image-cropper" className={cn("min-w-0", className)} {...props}> <div ref={box} role="group" tabIndex={status === "ready" ? 0 : -1} aria-roledescription={msg("image-cropper.role", "image cropper")} aria-label={alt ?? msg("image-cropper.label", "Crop the picture")} aria-describedby={`${id}-help`} onKeyDown={onKeyDown} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} data-dragging={dragging || undefined} data-state={status} className={cn( "relative touch-none select-none overflow-hidden rounded-xl bg-muted outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", status === "ready" && (dragging ? "cursor-grabbing" : "cursor-grab") )} style={{ height }} > {/* the picture, centred, then moved, turned and scaled; its size is its own until the scale is applied */} {src ? ( <img key={`${src}-${attempt}`} ref={img} src={src} alt="" draggable={false} crossOrigin={crossOrigin || undefined} onLoad={(e) => { const el = e.currentTarget setNatural({ width: el.naturalWidth, height: el.naturalHeight }) setStatus("ready") }} onError={() => { setStatus("error") onError?.(new Error(`The picture ${src} could not be loaded.`)) }} className="pointer-events-none absolute left-1/2 top-1/2 max-w-none origin-center will-change-transform" style={{ width: natural.width || undefined, height: natural.height || undefined, transform, opacity: status === "ready" ? 1 : 0 }} /> ) : null} {status === "ready" ? ( <> {/* the dimmed picture outside the frame, and the frame itself */} <div aria-hidden="true" className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div data-slot="image-cropper-frame" className={cn("relative ring-2 ring-white/90 [box-shadow:0_0_0_9999px_rgb(0_0_0/0.55)]", round ? "rounded-full" : "rounded-sm")} style={{ width: frame.width, height: frame.height }} > {showGrid ? ( <div className={cn("absolute inset-0 overflow-hidden transition-opacity duration-150 motion-reduce:transition-none", dragging ? "opacity-100" : "opacity-0", round && "rounded-full")}> <div className="absolute inset-y-0 start-1/3 w-px bg-white/60" /> <div className="absolute inset-y-0 start-2/3 w-px bg-white/60" /> <div className="absolute inset-x-0 top-1/3 h-px bg-white/60" /> <div className="absolute inset-x-0 top-2/3 h-px bg-white/60" /> </div> ) : null} </div> </div> </> ) : null} {status === "loading" ? ( <div role="status" className="absolute inset-0 flex items-center justify-center"> <Skeleton className="absolute inset-4 rounded-lg" /> <span className="sr-only">{msg("image-cropper.loading", "Loading the picture")}</span> </div> ) : null} {status === "error" ? ( <div role="alert" className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center"> <ImageOff className="size-8 text-muted-foreground" aria-hidden="true" /> <p className="text-sm font-medium">{msg("image-cropper.errorTitle", "The picture could not be loaded")}</p> <Button type="button" size="sm" variant="outline" onClick={() => setAttempt((n) => n + 1)}> {msg("image-cropper.retry", "Try again")} </Button> </div> ) : null} </div> <p id={`${id}-help`} className="sr-only"> {msg("image-cropper.help", "Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.")} </p> {controls ? ( <div data-slot="image-cropper-controls" className="mt-3 grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center"> <div className="grid gap-2"> <div className="flex items-center gap-2"> <ZoomOut className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> <input type="range" aria-label={msg("image-cropper.zoom", "Zoom")} aria-valuetext={msg("image-cropper.zoomValue", "{percent}%", { percent: Math.round(crop.zoom * 100) })} min={1} max={maxZoom} step={0.01} value={crop.zoom} disabled={status !== "ready"} onChange={(e) => set({ zoom: Number(e.target.value) })} onPointerUp={() => set({}, true)} onKeyUp={() => set({}, true)} className="h-1.5 w-full min-w-0 cursor-pointer accent-primary disabled:opacity-50" /> <ZoomIn className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> </div> <div className="flex items-center gap-2"> <RotateCcw className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> <input type="range" aria-label={msg("image-cropper.straighten", "Straighten")} aria-valuetext={msg("image-cropper.degrees", "{degrees} degrees", { degrees: Math.round(fine) })} min={-45} max={45} step={0.5} value={fine} disabled={status !== "ready"} onChange={(e) => set({ rotation: quarter + Number(e.target.value) })} onPointerUp={() => set({}, true)} onKeyUp={() => set({}, true)} className="h-1.5 w-full min-w-0 cursor-pointer accent-primary disabled:opacity-50" /> <RotateCw className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> </div> </div> <div className="flex items-center gap-1"> <Button type="button" variant="outline" size="icon" aria-label={msg("image-cropper.rotateLeft", "Turn left")} title={msg("image-cropper.rotateLeft", "Turn left")} disabled={status !== "ready"} onClick={() => turn(-90)}> <RotateCcw className="rtl:-scale-x-100" aria-hidden="true" /> </Button> <Button type="button" variant="outline" size="icon" aria-label={msg("image-cropper.rotateRight", "Turn right")} title={msg("image-cropper.rotateRight", "Turn right")} disabled={status !== "ready"} onClick={() => turn(90)}> <RotateCw className="rtl:-scale-x-100" aria-hidden="true" /> </Button> <Button type="button" variant="ghost" size="sm" disabled={status !== "ready"} onClick={() => (set({ ...START }, true), setSpoken(msg("image-cropper.resetDone", "Reset")))}> {msg("image-cropper.reset", "Reset")} </Button> </div> </div> ) : null} <span className="sr-only" role="status" aria-live="polite"> {spoken} </span> </div> ) } export { ImageCropper, type Crop, type CropArea, type CropImageOptions, type CropResult, type ImageCropperHandle, type ImageCropperProps }Update the import paths to match your project setup.
Usage
import { ImageCropper } from "@/components/ballmac/image-cropper"The full example is in the Code tab above.
Examples
Choose the shape
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
"use client"
import * as React from "react"
import { ImageCropper } from "@/components/ballmac/image-cropper"
/** A picture drawn in code, so the example needs no file: a sky, a sun, hills and a few houses, with enough detail to see what a crop keeps. */
const scene = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="rgb(255,183,120)"/><stop offset="0.55" stop-color="rgb(255,222,170)"/><stop offset="1" stop-color="rgb(190,215,255)"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#sky)"/><circle cx="1180" cy="330" r="130" fill="rgb(255,246,214)"/><path d="M0 680 Q300 480 620 640 T1240 600 T1600 660 V1000 H0Z" fill="rgb(112,148,120)"/><path d="M0 780 Q400 620 800 760 T1600 740 V1000 H0Z" fill="rgb(72,112,96)"/><g fill="rgb(245,235,220)"><rect x="300" y="700" width="120" height="90"/><rect x="470" y="730" width="90" height="70"/><rect x="1020" y="690" width="140" height="100"/></g><g fill="rgb(176,78,64)"><path d="M290 700 L360 640 L430 700Z"/><path d="M460 730 L515 685 L570 730Z"/><path d="M1010 690 L1090 620 L1170 690Z"/></g><g fill="rgb(40,60,52)"><rect x="880" y="690" width="14" height="70"/><circle cx="887" cy="672" r="42"/><rect x="1300" y="710" width="14" height="70"/><circle cx="1307" cy="692" r="38"/></g><text x="800" y="940" font-family="sans-serif" font-size="64" text-anchor="middle" fill="rgb(255,255,255)">GOLDEN HOUR</text></svg>`
)}`
const ratios = [
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "16:9", value: 16 / 9 },
{ label: "3:4", value: 3 / 4 },
]
export default function ImageCropperAspect() {
const [aspect, setAspect] = React.useState(16 / 9)
return (
<div className="grid w-full max-w-xl gap-3">
<div role="group" aria-label="Shape of the crop" className="flex gap-1.5">
{ratios.map((r) => (
<button key={r.label} type="button" aria-pressed={aspect === r.value} onClick={() => setAspect(r.value)} className="h-8 rounded-md border px-3 text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground">
{r.label}
</button>
))}
</div>
<ImageCropper src={scene} aspect={aspect} alt="Crop the sunset picture" height={300} />
</div>
)
}Round avatar from a file
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
"use client"
import * as React from "react"
import { Upload } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { ImageCropper, type ImageCropperHandle } from "@/components/ballmac/image-cropper"
/** A picture drawn in code, so the example needs no file: a sky, a sun, hills and a few houses, with enough detail to see what a crop keeps. */
const scene = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="rgb(255,183,120)"/><stop offset="0.55" stop-color="rgb(255,222,170)"/><stop offset="1" stop-color="rgb(190,215,255)"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#sky)"/><circle cx="1180" cy="330" r="130" fill="rgb(255,246,214)"/><path d="M0 680 Q300 480 620 640 T1240 600 T1600 660 V1000 H0Z" fill="rgb(112,148,120)"/><path d="M0 780 Q400 620 800 760 T1600 740 V1000 H0Z" fill="rgb(72,112,96)"/><g fill="rgb(245,235,220)"><rect x="300" y="700" width="120" height="90"/><rect x="470" y="730" width="90" height="70"/><rect x="1020" y="690" width="140" height="100"/></g><g fill="rgb(176,78,64)"><path d="M290 700 L360 640 L430 700Z"/><path d="M460 730 L515 685 L570 730Z"/><path d="M1010 690 L1090 620 L1170 690Z"/></g><g fill="rgb(40,60,52)"><rect x="880" y="690" width="14" height="70"/><circle cx="887" cy="672" r="42"/><rect x="1300" y="710" width="14" height="70"/><circle cx="1307" cy="692" r="38"/></g><text x="800" y="940" font-family="sans-serif" font-size="64" text-anchor="middle" fill="rgb(255,255,255)">GOLDEN HOUR</text></svg>`
)}`
export default function ImageCropperAvatar() {
const [src, setSrc] = React.useState(scene)
const [avatar, setAvatar] = React.useState<string | null>(null)
const cropper = React.useRef<ImageCropperHandle>(null)
const input = React.useRef<HTMLInputElement>(null)
// a picture chosen from the device is shown through an object URL, and given back when it is replaced
React.useEffect(() => () => void (src.startsWith("blob:") && URL.revokeObjectURL(src)), [src])
React.useEffect(() => () => void (avatar && URL.revokeObjectURL(avatar)), [avatar])
return (
<div className="grid w-full max-w-md gap-3">
<ImageCropper cropperRef={cropper} src={src} shape="round" aspect={1} alt="Crop your profile picture" height={280} />
<div className="flex items-center gap-3">
<input
ref={input}
type="file"
accept="image/*"
className="sr-only"
aria-label="Choose a picture"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setSrc(URL.createObjectURL(file))
}}
/>
<Button type="button" variant="outline" onClick={() => input.current?.click()}>
<Upload aria-hidden="true" />
Choose a picture
</Button>
<Button type="button" onClick={async () => setAvatar(URL.createObjectURL((await cropper.current?.toBlob({ width: 256, type: "image/png" }))!))}>
Use as avatar
</Button>
{avatar ? <img src={avatar} alt="Your new profile picture" className="ms-auto size-12 rounded-full border" /> : null}
</div>
</div>
)
}Controlled, with the area
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
- Zoom
- 1.60×
- Turn
- 8.0°
- Area
- –
- From
- –
"use client"
import * as React from "react"
import { ImageCropper, type Crop, type CropResult } from "@/components/ballmac/image-cropper"
/** A picture drawn in code, so the example needs no file: a sky, a sun, hills and a few houses, with enough detail to see what a crop keeps. */
const scene = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="rgb(255,183,120)"/><stop offset="0.55" stop-color="rgb(255,222,170)"/><stop offset="1" stop-color="rgb(190,215,255)"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#sky)"/><circle cx="1180" cy="330" r="130" fill="rgb(255,246,214)"/><path d="M0 680 Q300 480 620 640 T1240 600 T1600 660 V1000 H0Z" fill="rgb(112,148,120)"/><path d="M0 780 Q400 620 800 760 T1600 740 V1000 H0Z" fill="rgb(72,112,96)"/><g fill="rgb(245,235,220)"><rect x="300" y="700" width="120" height="90"/><rect x="470" y="730" width="90" height="70"/><rect x="1020" y="690" width="140" height="100"/></g><g fill="rgb(176,78,64)"><path d="M290 700 L360 640 L430 700Z"/><path d="M460 730 L515 685 L570 730Z"/><path d="M1010 690 L1090 620 L1170 690Z"/></g><g fill="rgb(40,60,52)"><rect x="880" y="690" width="14" height="70"/><circle cx="887" cy="672" r="42"/><rect x="1300" y="710" width="14" height="70"/><circle cx="1307" cy="692" r="38"/></g><text x="800" y="940" font-family="sans-serif" font-size="64" text-anchor="middle" fill="rgb(255,255,255)">GOLDEN HOUR</text></svg>`
)}`
export default function ImageCropperControlled() {
const [crop, setCrop] = React.useState<Crop>({ x: 0, y: 0, zoom: 1.6, rotation: 8 })
const [done, setDone] = React.useState<CropResult | null>(null)
return (
<div className="grid w-full max-w-xl gap-3">
<ImageCropper src={scene} aspect={4 / 3} value={crop} onCropChange={setCrop} onCropComplete={setDone} alt="Crop the sunset picture" height={280} />
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-lg border bg-muted/40 p-3 text-xs tabular-nums sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Zoom</dt>
<dd>{crop.zoom.toFixed(2)}×</dd>
</div>
<div>
<dt className="text-muted-foreground">Turn</dt>
<dd>{crop.rotation.toFixed(1)}°</dd>
</div>
<div>
<dt className="text-muted-foreground">Area</dt>
<dd>{done ? `${done.area.width}×${done.area.height}` : "–"}</dd>
</div>
<div>
<dt className="text-muted-foreground">From</dt>
<dd>{done ? `${done.area.x}, ${done.area.y}` : "–"}</dd>
</div>
</dl>
</div>
)
}Loading
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
import { ImageCropper } from "@/components/ballmac/image-cropper"
/** With no picture yet, the cropper holds its place and says it is loading. */
export default function ImageCropperLoading() {
return <ImageCropper src="" height={260} className="w-full max-w-md" />
}Could not load
Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.
import { ImageCropper } from "@/components/ballmac/image-cropper"
/** A picture that is not a picture: the cropper says so and offers another try. */
export default function ImageCropperError() {
return <ImageCropper src="data:image/png;base64,bm90IGFuIGltYWdl" height={260} className="w-full max-w-md" />
}API reference
| Prop | Type | Default |
|---|---|---|
src*The picture: a web address or an object URL. | string | — |
aspectWidth divided by height of the frame: 1 for a square, 16 / 9 for a banner. | number | 1 |
shapeA round frame shows what a round avatar will keep. | "rect" | "round" | "rect" |
value | Crop | — |
defaultValue | Partial<Crop> | — |
onCropChange | (crop: Crop) => void | — |
onCropCompleteCalled when a drag, a key press or a slider move ends, with the crop in pixels of the original. | (result: CropResult) => void | — |
maxZoomZoom the picture can reach. | number | 4 |
heightHeight of the picture area. | number | 320 |
gridShow thirds lines over the frame. | boolean | true |
zoomOnWheelZoom with the mouse wheel. Off if the cropper sits in a page people scroll through. | boolean | true |
controlsHide the zoom, straighten and rotate controls, to place your own. | boolean | true |
altWritten for a screen reader: what the picture shows. | string | — |
crossOrigin`crossorigin` for the picture. Needed to export one from another site. | "anonymous" | "use-credentials" | "" | "anonymous" |
onError | (error: Error) => void | — |
cropperRefGives you `toBlob`, `getCrop` and `reset`. | React.Ref<ImageCropperHandle> | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move the picture under the frame; Shift moves it further |
| + / - | Zoom in and out; the zoom is announced |
| [ / ] | Turn a quarter left or right |
| 0 | Reset |
| Sliders and buttons | Zoom and straighten are native range inputs with spoken values; turn and reset are buttons |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
33 tab stops, each showing a visible change when it takes focus. 5 documented keys.
Passed: Loading, empty and error
Ships loading, error states, each with an example.
Passed: Automated tests
Covered by image-cropper.test.tsx.
For information: Footprint
7.1 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<ImageCropper src aspect={1} cropperRef={ref} />. Get the file with await ref.current.toBlob({ width, type }); onCropComplete gives the area in original pixels to crop on a server. shape="round" for avatars. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Image Cropper (@ballmac/image-cropper) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Profile pictures, cover images and product photos cropped before upload
- Any upload that needs a fixed aspect ratio
- Straightening a slightly tilted photo
Not for
- Free-form selection boxes with resizable handles
- Photo editing with filters, text or drawing
- Pictures from another site without CORS headers, which cannot be exported
Registry JSON: https://ui.ballmac.com/r/image-cropper.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
File Dropzone
Drop files or browse with native file selection, type and size validation, removable file rows, and live errors.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.