Image Cropper
Pan, zoom, and rotate images with fixed or free aspect ratio and blob output.
@drift-ui/image-cropperRegistry JSONUse arrow keys to pan (hold Shift for 10x larger steps), plus and minus or bracket keys to zoom, R to rotate, and Enter to apply crop.
100%
On this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
Usage
"use client"
import { useState } from "react"
import { ImageCropper } from "@/components/ui/image-cropper"
export function AvatarUpload() {
const [blob, setBlob] = useState<Blob | null>(null)
return (
<ImageCropper
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=800"
aspect={1}
cropShape="round"
onCropComplete={(croppedBlob) => setBlob(croppedBlob)}
/>
)
}Examples
Avatar circular crop
Square 1:1 aspect ratio with a circular mask overlay for user profile photos.
Use arrow keys to pan (hold Shift for 10x larger steps), plus and minus or bracket keys to zoom, R to rotate, and Enter to apply crop.
100%
4:3 landscape framing
Standard 4:3 photo aspect ratio for photography and card assets.
Use arrow keys to pan (hold Shift for 10x larger steps), plus and minus or bracket keys to zoom, R to rotate, and Enter to apply crop.
100%
API reference
ImageCropper
An image cropping widget supporting pan, zoom, rotation, fixed aspect ratios, and Blob export.
| Prop | Type | Default |
|---|---|---|
srcRequiredstring | File | BlobSource image URL, File, or Blob. | string | File | Blob | |
aspectnumber | nullDefault1Aspect ratio (width / height) such as 1 (square), 16/9 (widescreen), or 4/3 (photo). | number | null | 1 |
cropShape"rect" | "round"Default"rect"Visual mask overlay shape. round displays a circular mask for avatars. | "rect" | "round" | "rect" |
zoomnumberControlled zoom scale. | number | |
defaultZoomnumberDefault1Initial zoom scale when uncontrolled. | number | 1 |
onZoomChange(zoom: number) => voidCalled when zoom scale changes. | (zoom: number) => void | |
minZoomnumberDefault1Minimum zoom scale. | number | 1 |
maxZoomnumberDefault3Maximum zoom scale. | number | 3 |
rotationnumberControlled rotation angle in degrees. | number | |
defaultRotationnumberDefault0Initial rotation angle in degrees. | number | 0 |
onRotationChange(rotation: number) => voidCalled when rotation angle changes. | (rotation: number) => void | |
cropPointControlled pan offset { x, y } in pixels. | Point | |
defaultCropPointDefault{ x: 0, y: 0 }Initial pan offset. | Point | { x: 0, y: 0 } |
onCropChange(crop: Point) => voidCalled when pan offset changes. | (crop: Point) => void | |
onCropComplete(blob: Blob, pixelCrop: PixelCrop) => voidCalled with the cropped image Blob and natural pixel crop dimensions when Apply is clicked or Enter is pressed. | (blob: Blob, pixelCrop: PixelCrop) => void | |
onCropError(error: Error) => voidCalled when image loading or canvas export fails. | (error: Error) => void | |
outputType"image/png" | "image/jpeg" | "image/webp"Default"image/png"MIME type for the generated Blob. | "image/png" | "image/jpeg" | "image/webp" | "image/png" |
outputQualitynumberDefault0.92Quality setting (0 to 1) for lossy formats. | number | 0.92 |
showControlsbooleanDefaulttrueShow zoom slider and rotate buttons below canvas. | boolean | true |
showGridbooleanDefaulttrueShow rule-of-thirds grid inside crop area. | boolean | true |
disabledbooleanDisables pan, zoom, and rotate interactions. | boolean |
Other props go to the <div>.
Source
"use client"
import * as React from "react"
import {
CheckIcon,
RotateCcwIcon,
RotateCwIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
canvasToBlob,
clampCrop,
computeCropSize,
computeInitialRenderSize,
computePixelCrop,
drawCroppedImageToCanvas,
type CropShape,
type PixelCrop,
type Point,
type Size,
} from "@/lib/image-cropper"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface ImageCropperProps extends Omit<
React.ComponentProps<"div">,
"onChange"
> {
src: string | File | Blob
aspect?: number | null
cropShape?: CropShape
zoom?: number
defaultZoom?: number
onZoomChange?: (zoom: number) => void
minZoom?: number
maxZoom?: number
rotation?: number
defaultRotation?: number
onRotationChange?: (rotation: number) => void
crop?: Point
defaultCrop?: Point
onCropChange?: (crop: Point) => void
onCropComplete?: (blob: Blob, pixelCrop: PixelCrop) => void
onCropError?: (error: Error) => void
outputType?: "image/png" | "image/jpeg" | "image/webp"
outputQuality?: number
showControls?: boolean
showGrid?: boolean
disabled?: boolean
}
function ImageCropper({
src,
aspect = 1,
cropShape = "rect",
zoom: controlledZoom,
defaultZoom = 1,
onZoomChange,
minZoom = 1,
maxZoom = 3,
rotation: controlledRotation,
defaultRotation = 0,
onRotationChange,
crop: controlledCrop,
defaultCrop = { x: 0, y: 0 },
onCropChange,
onCropComplete,
onCropError,
outputType = "image/png",
outputQuality = 0.92,
showControls = true,
showGrid = true,
disabled = false,
className,
"aria-label": ariaLabel = "Image cropper",
...props
}: ImageCropperProps) {
const { value: zoom, setValue: setZoom } = useControllableState({
value: controlledZoom,
defaultValue: defaultZoom,
onChange: onZoomChange,
})
const { value: rotation, setValue: setRotation } = useControllableState({
value: controlledRotation,
defaultValue: defaultRotation,
onChange: onRotationChange,
})
const { value: crop, setValue: setCrop } = useControllableState({
value: controlledCrop,
defaultValue: defaultCrop,
onChange: onCropChange,
})
const containerRef = React.useRef<HTMLDivElement>(null)
const imageRef = React.useRef<HTMLImageElement>(null)
const instructionsId = React.useId()
const [containerSize, setContainerSize] = React.useState<Size>({
width: 320,
height: 320,
})
const [naturalSize, setNaturalSize] = React.useState<Size>({
width: 0,
height: 0,
})
const [isDragging, setIsDragging] = React.useState(false)
const [statusAnnouncement, setStatusAnnouncement] = React.useState("")
const [objectUrl, setObjectUrl] = React.useState<string | null>(null)
const activePointers = React.useRef<Map<number, Point>>(new Map())
const pinchStart = React.useRef<{ distance: number; zoom: number } | null>(
null
)
const dragStart = React.useRef<{ pointer: Point; crop: Point }>({
pointer: { x: 0, y: 0 },
crop: { x: 0, y: 0 },
})
React.useEffect(() => {
if (
typeof window !== "undefined" &&
typeof src !== "string" &&
src instanceof Blob
) {
const url = URL.createObjectURL(src)
// eslint-disable-next-line react-hooks/set-state-in-effect
setObjectUrl(url)
return () => {
URL.revokeObjectURL(url)
}
}
setObjectUrl(null)
}, [src])
const imageSrc = typeof src === "string" ? src : (objectUrl ?? "")
React.useEffect(() => {
const el = containerRef.current
if (!el) return
const updateSize = () => {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
setContainerSize({
width: Math.round(rect.width),
height: Math.round(rect.height),
})
}
}
updateSize()
const observer = new ResizeObserver(updateSize)
observer.observe(el)
return () => observer.disconnect()
}, [])
const cropSize = React.useMemo(() => {
return computeCropSize(containerSize.width, containerSize.height, aspect)
}, [containerSize.width, containerSize.height, aspect])
const imageRenderSize = React.useMemo(() => {
return computeInitialRenderSize(naturalSize, cropSize, rotation)
}, [naturalSize, cropSize, rotation])
const updateZoom = React.useCallback(
(targetZoom: number) => {
const clampedZoom = Math.min(maxZoom, Math.max(minZoom, targetZoom))
const roundedZoom = Number(clampedZoom.toFixed(2))
setZoom(roundedZoom)
const nextCrop = clampCrop(
crop,
imageRenderSize,
cropSize,
roundedZoom,
rotation
)
setCrop(nextCrop)
setStatusAnnouncement(`Zoom ${Math.round(roundedZoom * 100)}%`)
},
[
maxZoom,
minZoom,
crop,
imageRenderSize,
cropSize,
rotation,
setZoom,
setCrop,
]
)
const commitCrop = React.useCallback(async () => {
if (
naturalSize.width <= 0 ||
naturalSize.height <= 0 ||
!imageRef.current
) {
return
}
const pixelCrop = computePixelCrop(
crop,
imageRenderSize,
cropSize,
naturalSize,
zoom,
rotation
)
try {
const canvas = drawCroppedImageToCanvas(
imageRef.current,
naturalSize,
pixelCrop,
rotation
)
const blob = await canvasToBlob(canvas, outputType, outputQuality)
onCropComplete?.(blob, pixelCrop)
setStatusAnnouncement("Crop applied")
} catch (err) {
onCropError?.(err instanceof Error ? err : new Error(String(err)))
}
}, [
crop,
imageRenderSize,
cropSize,
naturalSize,
zoom,
rotation,
outputType,
outputQuality,
onCropComplete,
onCropError,
])
function handleImageLoad(event: React.SyntheticEvent<HTMLImageElement>) {
const img = event.currentTarget
setNaturalSize({
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
})
}
function handleImageError() {
onCropError?.(new Error("Failed to load image source"))
}
function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (disabled || event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
activePointers.current.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
})
if (activePointers.current.size === 1) {
setIsDragging(true)
dragStart.current = {
pointer: { x: event.clientX, y: event.clientY },
crop: { ...crop },
}
} else if (activePointers.current.size === 2) {
setIsDragging(false)
const pts = Array.from(activePointers.current.values())
const distance = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y)
pinchStart.current = { distance, zoom }
}
}
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
if (disabled || !activePointers.current.has(event.pointerId)) return
activePointers.current.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
})
if (activePointers.current.size === 2 && pinchStart.current) {
const pts = Array.from(activePointers.current.values())
const currentDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y)
if (pinchStart.current.distance > 0) {
const factor = currentDist / pinchStart.current.distance
updateZoom(pinchStart.current.zoom * factor)
}
return
}
if (isDragging && activePointers.current.size === 1) {
const dx = event.clientX - dragStart.current.pointer.x
const dy = event.clientY - dragStart.current.pointer.y
const nextCrop = clampCrop(
{
x: dragStart.current.crop.x + dx,
y: dragStart.current.crop.y + dy,
},
imageRenderSize,
cropSize,
zoom,
rotation
)
setCrop(nextCrop)
}
}
function handlePointerUp(event: React.PointerEvent<HTMLDivElement>) {
activePointers.current.delete(event.pointerId)
try {
event.currentTarget.releasePointerCapture(event.pointerId)
} catch {}
if (activePointers.current.size === 0) {
setIsDragging(false)
pinchStart.current = null
} else if (activePointers.current.size === 1) {
pinchStart.current = null
const remaining = Array.from(activePointers.current.values())[0]
dragStart.current = {
pointer: { x: remaining.x, y: remaining.y },
crop: { ...crop },
}
setIsDragging(true)
}
}
React.useEffect(() => {
const el = containerRef.current
if (!el || disabled) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
const step = e.deltaY < 0 ? 0.1 : -0.1
updateZoom(zoom + step)
}
el.addEventListener("wheel", onWheel, { passive: false })
return () => el.removeEventListener("wheel", onWheel)
}, [disabled, zoom, updateZoom])
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return
const step = event.shiftKey ? 50 : 5
let dx = 0
let dy = 0
if (event.key === "ArrowLeft") dx = -step
else if (event.key === "ArrowRight") dx = step
else if (event.key === "ArrowUp") dy = -step
else if (event.key === "ArrowDown") dy = step
else if (event.key === "+" || event.key === "=" || event.key === "]") {
event.preventDefault()
updateZoom(zoom + 0.1)
return
} else if (event.key === "-" || event.key === "_" || event.key === "[") {
event.preventDefault()
updateZoom(zoom - 0.1)
return
} else if (event.key === "r" || event.key === "R") {
event.preventDefault()
handleRotate(90)
return
} else if (event.key === "Enter") {
event.preventDefault()
commitCrop()
return
} else {
return
}
event.preventDefault()
const nextCrop = clampCrop(
{ x: crop.x + dx, y: crop.y + dy },
imageRenderSize,
cropSize,
zoom,
rotation
)
setCrop(nextCrop)
setStatusAnnouncement(
`Position ${Math.round(nextCrop.x)}, ${Math.round(nextCrop.y)}`
)
}
function handleRotate(delta: number) {
if (disabled) return
const nextRotation = (((rotation + delta) % 360) + 360) % 360
setRotation(nextRotation)
const nextRenderSize = computeInitialRenderSize(
naturalSize,
cropSize,
nextRotation
)
const nextCrop = clampCrop(
crop,
nextRenderSize,
cropSize,
zoom,
nextRotation
)
setCrop(nextCrop)
setStatusAnnouncement(`Rotated to ${nextRotation} degrees`)
}
return (
<div
data-slot="image-cropper"
className={cn(
"flex flex-col gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xs",
disabled && "pointer-events-none opacity-50",
className
)}
{...props}
>
<div id={instructionsId} className="sr-only">
Use arrow keys to pan (hold Shift for 10x larger steps), plus and minus
or bracket keys to zoom, R to rotate, and Enter to apply crop.
</div>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusAnnouncement}
</div>
<div
ref={containerRef}
role="region"
aria-label={ariaLabel}
aria-describedby={instructionsId}
tabIndex={disabled ? -1 : 0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
className={cn(
"relative flex h-72 w-full touch-none items-center justify-center overflow-hidden rounded-md bg-black/90 outline-none select-none sm:h-80",
isDragging ? "cursor-grabbing" : "cursor-grab",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
>
{imageSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img
ref={imageRef}
src={imageSrc}
alt="Source preview"
crossOrigin="anonymous"
onLoad={handleImageLoad}
onError={handleImageError}
style={{
width: imageRenderSize.width || "auto",
height: imageRenderSize.height || "auto",
transform: `translate(${crop.x}px, ${crop.y}px) rotate(${rotation}deg) scale(${zoom})`,
transformOrigin: "center center",
}}
className="pointer-events-none max-h-none max-w-none transition-transform duration-75 ease-out"
/>
)}
<div
data-slot="crop-area"
style={{
width: cropSize.width,
height: cropSize.height,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.65)",
}}
className={cn(
"pointer-events-none absolute border border-white/80 transition-all",
cropShape === "round" ? "rounded-full" : "rounded-xs"
)}
>
{showGrid && cropShape === "rect" && (
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
<div className="border-r border-b border-white/30" />
<div className="border-r border-b border-white/30" />
<div className="border-b border-white/30" />
<div className="border-r border-b border-white/30" />
<div className="border-r border-b border-white/30" />
<div className="border-b border-white/30" />
<div className="border-r border-b border-white/30" />
<div className="border-r border-b border-white/30" />
<div />
</div>
)}
</div>
</div>
{showControls && (
<div
data-slot="crop-controls"
className="flex flex-wrap items-center justify-between gap-3 pt-1"
>
<div className="flex min-w-44 flex-1 items-center gap-2">
<ZoomOutIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="range"
aria-label="Zoom level"
aria-valuemin={minZoom}
aria-valuemax={maxZoom}
aria-valuenow={zoom}
aria-valuetext={`${Math.round(zoom * 100)}%`}
min={minZoom}
max={maxZoom}
step={0.05}
disabled={disabled}
value={zoom}
onChange={(e) => updateZoom(parseFloat(e.target.value))}
className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-secondary accent-primary"
/>
<ZoomInIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
onClick={() => handleRotate(-90)}
aria-label="Rotate counter-clockwise"
title="Rotate counter-clockwise"
>
<RotateCcwIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
onClick={() => handleRotate(90)}
aria-label="Rotate clockwise"
title="Rotate clockwise"
>
<RotateCwIcon className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
disabled={disabled}
onClick={() => commitCrop()}
className="gap-1.5"
>
<CheckIcon className="h-4 w-4" />
Apply
</Button>
</div>
</div>
)}
</div>
)
}
export {
ImageCropper,
type ImageCropperProps,
type CropShape,
type PixelCrop,
type Point,
type Size,
}