Image Cropper
Crop an image: drag the frame, pull a corner to resize, lock an aspect ratio, zoom, and read the exact output size.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/image-cropper.json'use client'
import { useRef, useState } from 'react'
import './image-cropper.css'
type R = { x: number; y: number; w: number; h: number }
export interface ImageCropperProps {
width?: number
height?: number
aspect?: 'free' | number
guides?: 'drag' | 'always'
onCrop?: (r: R) => void
children?: React.ReactNode
fill?: 'dark' | 'light'
className?: string
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
/** Crop frame with move, corner resize, aspect lock and zoom. Coordinates are fractions of the stage. */
export function ImageCropper({ width = 1200, height = 800, aspect: a0 = 1, guides = 'drag', onCrop, children, fill = 'dark', className }: ImageCropperProps) {
const ratio = width / height
const fit = (asp: 'free' | number, r?: R): R => {
if (asp === 'free') return r ?? { x: 0.15, y: 0.15, w: 0.7, h: 0.7 }
const h = Math.min(0.8, 0.8 * ratio / asp)
const w = (h * asp) / ratio
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }
}
const [aspect, setAspect] = useState<'free' | number>(a0)
const [r, setR] = useState<R>(() => fit(a0))
const [zoom, setZoom] = useState(1)
const [drag, setDrag] = useState(false)
const stage = useRef<HTMLDivElement>(null)
const st = useRef<{ px: number; py: number; r: R; mode: 'move' | 'size' } | null>(null)
const commit = (n: R) => { setR(n); onCrop?.(px(n)) }
const px = (n: R) => ({ x: Math.round(n.x * width / zoom + width * (1 - 1 / zoom) / 2), y: Math.round(n.y * height / zoom + height * (1 - 1 / zoom) / 2), w: Math.round(n.w * width / zoom), h: Math.round(n.h * height / zoom) })
const resize = (b: R, w: number, h: number): R => {
if (aspect !== 'free') h = (w * ratio) / aspect
w = clamp(w, 0.12, 1 - b.x); h = clamp(h, 0.12, 1 - b.y)
if (aspect !== 'free') { const w2 = (h * aspect) / ratio; if (w2 < w) w = w2; else h = (w * ratio) / aspect }
return { ...b, w, h }
}
const down = (mode: 'move' | 'size') => (e: React.PointerEvent) => {
e.stopPropagation(); e.preventDefault()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
st.current = { px: e.clientX, py: e.clientY, r, mode }; setDrag(true)
}
const move = (e: React.PointerEvent) => {
const s = st.current, box = stage.current?.getBoundingClientRect()
if (!s || !box) return
const dx = (e.clientX - s.px) / box.width, dy = (e.clientY - s.py) / box.height
if (s.mode === 'move') commit({ ...s.r, x: clamp(s.r.x + dx, 0, 1 - s.r.w), y: clamp(s.r.y + dy, 0, 1 - s.r.h) })
else commit(resize(s.r, s.r.w + dx, s.r.h + dy))
}
const up = () => { st.current = null; setDrag(false) }
const key = (e: React.KeyboardEvent) => {
const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]
if (!d) return
e.preventDefault()
const k = 0.02
if (e.shiftKey) commit(resize(r, r.w + d[0] * k, r.h + d[1] * k))
else commit({ ...r, x: clamp(r.x + d[0] * k, 0, 1 - r.w), y: clamp(r.y + d[1] * k, 0, 1 - r.h) })
}
const o = px(r)
const pct = (v: number) => `${v * 100}%`
return (
<div className={['sd-crp', className].filter(Boolean).join(' ')} data-fill={fill} data-grid={guides} data-drag={drag || undefined}>
<div ref={stage} className="sd-crp__stage" onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<div className="sd-crp__img" style={{ transform: `scale(${zoom})` }}>{children}</div>
<div className="sd-crp__frame" role="group" tabIndex={0} aria-label="Crop area. Arrows move, Shift and arrows resize."
style={{ left: pct(r.x), top: pct(r.y), width: pct(r.w), height: pct(r.h) }} onPointerDown={down('move')} onKeyDown={key}>
<span className="sd-crp__g" /><span className="sd-crp__g" /><span className="sd-crp__c" data-p="tl" /><span className="sd-crp__c" data-p="tr" /><span className="sd-crp__c" data-p="bl" />
<span className="sd-crp__h" onPointerDown={down('size')} aria-hidden />
</div>
</div>
<div className="sd-crp__bar">
<div className="sd-crp__seg" role="radiogroup" aria-label="Aspect ratio">
{([['free', 'Free'], [1, '1:1'], [16 / 9, '16:9']] as const).map(([v, l]) => (
<button key={l} type="button" role="radio" aria-checked={aspect === 'free' ? v === 'free' : typeof v === 'number' && Math.abs(v - (aspect as number)) < 0.01}
onClick={() => { setAspect(v); commit(fit(v, r)) }}>{l}</button>
))}
</div>
<input className="sd-crp__zoom" type="range" min={1} max={2.5} step={0.05} value={zoom} aria-label="Zoom" onChange={(e) => setZoom(Number(e.target.value))} />
</div>
<p className="sd-crp__out" aria-live="polite"><span>Output <b>{o.w} × {o.h}</b></span><span>at {o.x}, {o.y}</span></p>
</div>
)
}.sd-crp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-crp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-crp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-crp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-crp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-crp { width: min(100%, 340px); padding: 12px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-crp__stage { position: relative; aspect-ratio: 3 / 2; border-radius: 10px; overflow: hidden; touch-action: none; user-select: none; background: var(--soft); }
.sd-crp__img { position: absolute; inset: 0; width: 100%; height: 100%; transform-origin: center; transition: transform 160ms; }
.sd-crp__shade { position: absolute; inset: 0; pointer-events: none; }
.sd-crp__frame { position: absolute; box-shadow: 0 0 0 999px rgb(0 0 0 / 0.55), inset 0 0 0 1.5px #fff; cursor: move; outline: 0; }
.sd-crp__frame:focus-visible { box-shadow: 0 0 0 999px rgb(0 0 0 / 0.55), inset 0 0 0 2.5px #fff; }
.sd-crp__g { position: absolute; inset: 0 33.333%; border: 0 solid rgb(255 255 255 / 0.5); border-width: 0 1px; opacity: 0; transition: opacity 150ms; pointer-events: none; }
.sd-crp__g + .sd-crp__g { inset: 33.333% 0; border-width: 1px 0; }
.sd-crp[data-grid='always'] .sd-crp__g, .sd-crp[data-drag] .sd-crp__g { opacity: 1; }
.sd-crp__h { position: absolute; right: -7px; bottom: -7px; width: 18px; height: 18px; border-radius: 4px; background: #fff; cursor: nwse-resize; box-shadow: 0 1px 4px rgb(0 0 0 / 0.4); }
.sd-crp__c { position: absolute; width: 14px; height: 14px; border: 0 solid #fff; pointer-events: none; }
.sd-crp__c[data-p='tl'] { left: -2px; top: -2px; border-width: 3px 0 0 3px; }
.sd-crp__c[data-p='tr'] { right: -2px; top: -2px; border-width: 3px 3px 0 0; }
.sd-crp__c[data-p='bl'] { left: -2px; bottom: -2px; border-width: 0 0 3px 3px; }
.sd-crp__bar { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.sd-crp__seg { display: flex; padding: 2px; border-radius: 9px; background: var(--soft); }
.sd-crp__seg button { padding: 5px 9px; border-radius: 7px; font-size: 12px; color: var(--mute) !important; }
.sd-crp__seg button[aria-checked='true'] { background: var(--bg); color: var(--fg) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); }
.sd-crp__zoom { flex: 1; min-width: 0; accent-color: var(--fg); }
.sd-crp__out { margin: 10px 2px 0; display: flex; justify-content: space-between; font-size: 12px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-crp__out b { color: var(--fg); font-weight: 600; }
@media (prefers-reduced-motion: reduce) { .sd-crp *, .sd-crp *::before, .sd-crp *::after { animation: none !important; transition: none !important; } }Overview
The frame moves by dragging inside it and resizes from its corner; it never leaves the image. Aspect presets snap the frame and keep it locked while you resize.
Thirds lines appear while you drag. The footer reports the crop in source pixels, ready to send to your resizer.
When to use
- Avatars, cover images, product shots before upload.
When not to use
- Full photo editing with rotation and filters.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/image-cropper.json$
npx shadcn@latest add https://sundaeui.dev/r/image-cropper.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/image-cropper.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/image-cropper.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy image-cropper.tsx and image-cropper.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ImageCropper } from '@/components/sundae/image-cropper'
<ImageCropper />Props
| Prop | Type | Default |
|---|---|---|
srcImage URL (the demo draws an illustration). | string | — |
width / heightSource size in pixels. | number | 1200 / 800 |
aspectLocked ratio. | 'free' | number | 'free' |
onCropCalled with the crop in source pixels. | (r: { x; y; w; h }) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The frame is a focusable group: arrows move it, Shift+arrows resize it.
- The output size is announced as it changes.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-crp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move the frame
- Shift + arrows
- Resize the frame
FAQ
- Is Image Cropper free?
- Yes. Image Cropper is free for personal and commercial projects, with full source on this page.
- How do I install Image Cropper?
- Run npx shadcn@latest add https://sundaeui.dev/r/image-cropper.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Image Cropper support keyboard and screen readers?
- The frame is a focusable group: arrows move it, Shift+arrows resize it. The output size is announced as it changes.
- Does Image Cropper respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Avatar Stack
Components · RefinedSplit Pane
Components · RefinedVideo Trimmer
Components · RefinedHotspot Image
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined