TL;DR — A React image crop tool from scratch is ~400 lines: a draggable selection box overlay, eight resize handles, optional aspect ratio lock, and a Canvas API call to produce the output blob. This tutorial walks through every piece. The hard parts are pointer math, handle hit-testing, and getting the output coordinates right at any scale.
You can pnpm add react-image-crop and ship in 10 minutes. But every product team eventually wants to customize the cropper — different handle styles, different aspect ratios, branded overlay colors, integrated with their upload flow — and that's when knowing how to build one yourself becomes useful. This guide builds a complete React image crop tool with no external dependencies beyond React.
If you want to skip ahead, UploadKit ships a <Cropper> component that does all of this with a themed design system, dark mode, keyboard support, and a touch-friendly variant. But understanding the underlying mechanics will make you a better consumer of any cropping library.
The architecture
The cropper has four moving parts:
- The image layer — the source image rendered at a fixed display size.
- The dim overlay — a semi-transparent layer over everything except the crop region.
- The crop box — the visible rectangle the user drags and resizes.
- The handles — eight small squares (corners + edges) for resizing.
When the user clicks "Crop", we take the crop box coordinates (in display pixels), scale them to the natural image dimensions, and draw that region to a <canvas> to produce a Blob.
The data model
Three pieces of state describe the cropper:
type CropBox = {
x: number // left, in display pixels
y: number // top, in display pixels
width: number // in display pixels
height: number // in display pixels
}
type DragMode =
| { type: 'idle' }
| { type: 'move'; offsetX: number; offsetY: number }
| { type: 'resize'; handle: Handle; startBox: CropBox; startX: number; startY: number }
type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'CropBox is in the display coordinate space — what the user sees. We convert to natural coordinates only when producing the output blob.
The component shell
'use client'
import { useEffect, useRef, useState } from 'react'
type Props = {
src: string
aspectRatio?: number // e.g., 1 for square, 16/9 for widescreen
onCrop: (blob: Blob) => void
}
export function Cropper({ src, aspectRatio, onCrop }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const [box, setBox] = useState<CropBox | null>(null)
const [drag, setDrag] = useState<DragMode>({ type: 'idle' })
const [imgSize, setImgSize] = useState({ width: 0, height: 0 })
// ... handlers below
return (
<div
ref={containerRef}
style={{ position: 'relative', userSelect: 'none', display: 'inline-block' }}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<img
ref={imgRef}
src={src}
alt=""
draggable={false}
onLoad={handleImageLoad}
style={{ display: 'block', maxWidth: '100%' }}
/>
{box && (
<>
<Overlay imgSize={imgSize} box={box} />
<CropBoxView box={box} onPointerDown={handlePointerDown} />
</>
)}
</div>
)
}Initializing the crop box
When the image loads, place a default crop box — centered, 80% of the smaller dimension, respecting aspect ratio:
function handleImageLoad() {
const img = imgRef.current
if (!img) return
const w = img.clientWidth
const h = img.clientHeight
setImgSize({ width: w, height: h })
const ratio = aspectRatio ?? w / h
let cropW = Math.min(w, h * ratio) * 0.8
let cropH = cropW / ratio
setBox({
x: (w - cropW) / 2,
y: (h - cropH) / 2,
width: cropW,
height: cropH,
})
}Use clientWidth/clientHeight (display size), not naturalWidth/naturalHeight (intrinsic size). The crop box lives in display space.
The dim overlay
The overlay is four rectangles around the crop box, each filled with rgba(0,0,0,0.5). We use four divs instead of a clip-path because divs are cheap and work everywhere:
function Overlay({ imgSize, box }: { imgSize: { width: number; height: number }; box: CropBox }) {
const dim = 'rgba(0,0,0,0.55)'
return (
<>
{/* top */}
<div style={{ position: 'absolute', left: 0, top: 0, width: imgSize.width, height: box.y, background: dim, pointerEvents: 'none' }} />
{/* bottom */}
<div style={{ position: 'absolute', left: 0, top: box.y + box.height, width: imgSize.width, height: imgSize.height - (box.y + box.height), background: dim, pointerEvents: 'none' }} />
{/* left */}
<div style={{ position: 'absolute', left: 0, top: box.y, width: box.x, height: box.height, background: dim, pointerEvents: 'none' }} />
{/* right */}
<div style={{ position: 'absolute', left: box.x + box.width, top: box.y, width: imgSize.width - (box.x + box.width), height: box.height, background: dim, pointerEvents: 'none' }} />
</>
)
}pointerEvents: 'none' is important — the overlay must not capture pointer events that should reach the crop box.
The crop box and handles
const HANDLE_SIZE = 12
function CropBoxView({
box,
onPointerDown,
}: {
box: CropBox
onPointerDown: (e: React.PointerEvent, target: 'move' | Handle) => void
}) {
const handles: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
return (
<div
style={{
position: 'absolute',
left: box.x,
top: box.y,
width: box.width,
height: box.height,
border: '1px solid white',
boxShadow: '0 0 0 1px rgba(0,0,0,0.2)',
cursor: 'move',
}}
onPointerDown={(e) => onPointerDown(e, 'move')}
>
{handles.map((h) => (
<div
key={h}
onPointerDown={(e) => {
e.stopPropagation()
onPointerDown(e, h)
}}
style={{
position: 'absolute',
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: 'white',
border: '1px solid rgba(0,0,0,0.4)',
cursor: handleCursor(h),
...handlePosition(h),
}}
/>
))}
</div>
)
}
function handlePosition(h: Handle): React.CSSProperties {
const half = -HANDLE_SIZE / 2
switch (h) {
case 'nw': return { left: half, top: half }
case 'n': return { left: '50%', top: half, transform: 'translateX(-50%)' }
case 'ne': return { right: half, top: half }
case 'e': return { right: half, top: '50%', transform: 'translateY(-50%)' }
case 'se': return { right: half, bottom: half }
case 's': return { left: '50%', bottom: half, transform: 'translateX(-50%)' }
case 'sw': return { left: half, bottom: half }
case 'w': return { left: half, top: '50%', transform: 'translateY(-50%)' }
}
}
function handleCursor(h: Handle): string {
return ({ nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize', e: 'e-resize',
se: 'se-resize', s: 's-resize', sw: 'sw-resize', w: 'w-resize' })[h]
}e.stopPropagation() on the handle prevents the move handler on the parent from also firing.
Pointer math
The container is the coordinate origin. Translate page coordinates to container coordinates with getBoundingClientRect():
function getLocalCoords(e: React.PointerEvent | PointerEvent) {
const rect = containerRef.current!.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function handlePointerDown(e: React.PointerEvent, target: 'move' | Handle) {
if (!box) return
const { x, y } = getLocalCoords(e)
;(e.target as Element).setPointerCapture(e.pointerId)
if (target === 'move') {
setDrag({ type: 'move', offsetX: x - box.x, offsetY: y - box.y })
} else {
setDrag({ type: 'resize', handle: target, startBox: { ...box }, startX: x, startY: y })
}
}setPointerCapture ensures we keep receiving pointer events even when the cursor leaves the element — critical when the user drags fast.
Move and resize handlers
function handlePointerMove(e: React.PointerEvent) {
if (drag.type === 'idle' || !box) return
const { x, y } = getLocalCoords(e)
if (drag.type === 'move') {
const newX = clamp(x - drag.offsetX, 0, imgSize.width - box.width)
const newY = clamp(y - drag.offsetY, 0, imgSize.height - box.height)
setBox({ ...box, x: newX, y: newY })
return
}
// resize
const dx = x - drag.startX
const dy = y - drag.startY
const next = applyResize(drag.startBox, drag.handle, dx, dy, aspectRatio, imgSize)
setBox(next)
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}The resize math is the trickiest part. Each handle moves a different combination of edges:
function applyResize(
start: CropBox,
handle: Handle,
dx: number,
dy: number,
aspectRatio: number | undefined,
bounds: { width: number; height: number },
): CropBox {
let { x, y, width, height } = start
if (handle.includes('e')) width = start.width + dx
if (handle.includes('w')) { width = start.width - dx; x = start.x + dx }
if (handle.includes('s')) height = start.height + dy
if (handle.includes('n')) { height = start.height - dy; y = start.y + dy }
// Lock aspect ratio if specified
if (aspectRatio) {
if (handle === 'e' || handle === 'w') {
height = width / aspectRatio
} else if (handle === 'n' || handle === 's') {
width = height * aspectRatio
} else {
// corner: use whichever dimension changed more
if (Math.abs(dx) > Math.abs(dy)) height = width / aspectRatio
else width = height * aspectRatio
}
if (handle.includes('w')) x = start.x + (start.width - width)
if (handle.includes('n')) y = start.y + (start.height - height)
}
// Min size + bounds
const MIN = 24
if (width < MIN || height < MIN) return start
if (x < 0 || y < 0 || x + width > bounds.width || y + height > bounds.height) return start
return { x, y, width, height }
}
function handlePointerUp() {
setDrag({ type: 'idle' })
}The aspect-ratio logic is the part most off-the-shelf croppers get subtly wrong. When dragging a corner with aspect lock, you have to pick one axis as authoritative — we pick whichever the user moved more. This feels natural because it follows the user's intent.
Producing the output blob
When the user clicks "Crop", scale the box from display coords to natural coords and draw to a canvas:
async function exportCrop() {
const img = imgRef.current
if (!img || !box) return
const scaleX = img.naturalWidth / img.clientWidth
const scaleY = img.naturalHeight / img.clientHeight
const sx = box.x * scaleX
const sy = box.y * scaleY
const sw = box.width * scaleX
const sh = box.height * scaleY
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('toBlob failed'))),
'image/jpeg',
0.92,
)
})
onCrop(blob)
}Three things worth noting:
- Always scale to natural coordinates before drawing. Drawing the display-sized box gives you a low-res output.
- Use
image/jpegfor photos,image/webpfor better compression in modern browsers. PNG is fine for graphics. - Quality 0.92 is the sweet spot — visually identical to 1.0 but ~30% smaller.
CORS gotcha
If the image is hosted on a different origin, drawImage will throw a security error when you call toBlob. The image must either:
- Be served with
Access-Control-Allow-Origin: *(or your origin), and - Be loaded with
crossOrigin="anonymous"on the<img>.
For images uploaded to your own R2/S3 bucket, configure CORS once and you're good. For arbitrary user-supplied URLs, you'll need a proxy that re-serves the image with the right headers.
Putting it together
export default function CropDemo() {
const [src, setSrc] = useState<string | null>(null)
const [output, setOutput] = useState<string | null>(null)
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setSrc(URL.createObjectURL(file))
}}
/>
{src && (
<Cropper
src={src}
aspectRatio={1}
onCrop={(blob) => setOutput(URL.createObjectURL(blob))}
/>
)}
{output && <img src={output} alt="cropped" />}
</div>
)
}That's a working cropper in around 400 lines once you add styling and the export button. Touch support comes for free because we used PointerEvent instead of MouseEvent.
What this means for you
You now know how a cropper works under the hood. The pieces that look complicated in the wild (react-image-crop, react-easy-crop, etc.) are mostly handle layout and aspect-ratio math — the same code you just wrote.
If you're shipping a real product, the rough edges to add are:
- Keyboard support. Arrow keys to nudge, shift+arrow for bigger steps, tab between handles.
- Touch ergonomics. Larger handle hit areas on mobile (use a transparent padding wrapper).
- Reduced motion. Don't animate the box on prefers-reduced-motion.
- Aspect ratio toggle. Many products want "free" or "1:1" or "16:9" as a chip group above the cropper.
- Output formats. Let the consumer pick JPEG/WebP/PNG and quality.
Or use UploadKit's <Cropper> which handles all of the above plus dark mode, theming via CSS custom properties, and integration with the upload pipeline so the cropped blob goes straight to storage with no extra glue:
import { Cropper } from '@uploadkitdev/react'
<Cropper
src={src}
aspectRatio={1}
onCrop={async (blob) => await upload(blob)}
/>Related reading:
- How to upload files to Cloudflare R2 in Next.js
- Presigned URLs vs server proxy uploads: which to choose
External references: