Image Cropper in React: Crop, Zoom, Rotate Before Upload
Build a full image cropper in React — crop area selection, pinch zoom, rotation, and canvas export — before the file ever hits your server.
Why Crop Before Upload
Most devs think cropping is a server problem. It's not. The moment a user drops a 4 MB HEIC file onto your avatar input and you blindly POST it, you've already lost — slow uploads, wasted bandwidth, and backend resize logic that lives in three different places. Move the crop step to the client. Users see exactly what they're submitting, you get normalized dimensions, and your storage bill stops creeping up.
The browser has had canvas-based pixel manipulation since 2012. As of React 18, the primitives you need are all there: useRef on a canvas element, the CanvasRenderingContext2D API for transforms, and a FileReader to turn the <input type='file'> result into a drawable image. No giant dependency tree required — though a thin helper library is fine if you want drag handles without writing 300 lines of pointer math.
Worth noting: the popular react-image-crop package (maintained, v11 as of 2026) covers the drag-selection part cleanly. Pair it with your own canvas export and you get a complete flow without pulling in a monolith. That's the approach we'll use here.
Honestly, the "pure canvas" path is great for learning but painful to maintain. Pick the right abstraction, own the canvas export code, and keep the surface area small.
Project Setup and Dependencies
You need three things: react-image-crop, which handles the visual crop box, react, obviously, and that's pretty much it. Drop this into your terminal:
``bash
npm install react-image-crop
# or
pnpm add react-image-crop
`
You'll also want the library's CSS. Import it once at your app root or in your component file:
`tsx
import 'react-image-crop/dist/ReactCrop.css';
``
Quick aside: if you're on Next.js, put that CSS import in app/layout.tsx or pages/_app.tsx — wherever your global styles live. Forgetting it means the crop handles render invisibly, which is exactly as fun as it sounds.
The library ships with full TypeScript types. No @types/ package needed, which saves you from the ritual of installing a type package that's six versions behind the actual package. Structure your component file like this:
``
src/
components/
ImageCropper/
index.tsx ← main component
useCropCanvas.ts ← canvas export hook
types.ts ← shared types
``
Keeping the canvas logic in its own hook makes the main component readable and makes the export function testable in isolation.
Building the Crop Component
Here's the core component. It handles file input, renders the crop UI, and wires up state — no crop canvas export yet, that comes next:
``tsx
import { useState, useRef, useCallback } from 'react';
import ReactCrop, {
centerCrop,
makeAspectCrop,
type Crop,
type PixelCrop,
} from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
function centerAspectCrop(width: number, height: number, aspect: number): Crop {
return centerCrop(
makeAspectCrop({ unit: '%', width: 80 }, aspect, width, height),
width,
height
);
}
export function ImageCropper({ aspect = 1 }: { aspect?: number }) {
const [imgSrc, setImgSrc] = useState('');
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [rotation, setRotation] = useState(0);
const [scale, setScale] = useState(1);
const imgRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setImgSrc(reader.result as string);
reader.readAsDataURL(file);
}, []);
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget;
setCrop(centerAspectCrop(width, height, aspect));
}, [aspect]);
return (
<div className="image-cropper">
<input type="file" accept="image/*" onChange={onFileChange} />
{imgSrc && (
<>
<div style={{ display: 'flex', gap: 16, marginBlock: 12 }}>
<label>
Zoom
<input
type="range" min={0.5} max={3} step={0.1}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
</label>
<label>
Rotate
<input
type="range" min={-180} max={180} step={1}
value={rotation}
onChange={(e) => setRotation(Number(e.target.value))}
/>
</label>
</div>
<ReactCrop
crop={crop}
onChange={setCrop}
onComplete={setCompletedCrop}
aspect={aspect}
>
<img
ref={imgRef}
src={imgSrc}
alt="Crop source"
style={{
transform: scale(${scale}) rotate(${rotation}deg),
maxWidth: '100%',
}}
onLoad={onImageLoad}
/>
</ReactCrop>
<canvas ref={canvasRef} style={{ display: 'none' }} />
</>
)}
</div>
);
}
``
A few things worth calling out here. The centerAspectCrop helper pre-selects an 80% wide centered crop region on image load — users immediately see something useful instead of a blank selection. The scale and rotation are CSS transforms on the displayed image, which is fine for the visual layer; the actual pixel math happens during canvas export, not here.
That said, CSS transform and canvas pixel extraction don't mix automatically. When you rotate the image visually with CSS and then try to drawImage from it, the canvas doesn't know about the CSS rotation. That's why the export step needs to re-apply the transforms itself using canvas context.rotate() — more on that in the next section.
Canvas Export: The Part Everyone Gets Wrong
Canvas export is where most blog posts wave their hands. Here's the actual code that produces a cropped, rotated, zoomed Blob from your pixel region:
``ts
// useCropCanvas.ts
import { useCallback } from 'react';
import type { PixelCrop } from 'react-image-crop';
export function useCropCanvas(
imgRef: React.RefObject<HTMLImageElement | null>,
canvasRef: React.RefObject<HTMLCanvasElement | null>,
) {
return useCallback(
async (
completedCrop: PixelCrop,
scale: number,
rotation: number,
): Promise<Blob | null> => {
const img = imgRef.current;
const canvas = canvasRef.current;
if (!img || !canvas || !completedCrop.width || !completedCrop.height) {
return null;
}
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Output at 2x for retina — 400px output = 800px canvas pixels
const pixelRatio = 2;
canvas.width = Math.floor(completedCrop.width * scaleX * pixelRatio);
canvas.height = Math.floor(completedCrop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = 'high';
const cropX = completedCrop.x * scaleX;
const cropY = completedCrop.y * scaleY;
// Move origin to center of crop, apply rotation + scale, draw
const centerX = img.naturalWidth / 2;
const centerY = img.naturalHeight / 2;
ctx.save();
ctx.translate(-cropX, -cropY);
ctx.translate(centerX, centerY);
ctx.rotate((rotation * Math.PI) / 180);
ctx.scale(scale, scale);
ctx.translate(-centerX, -centerY);
ctx.drawImage(img, 0, 0);
ctx.restore();
return new Promise((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.92);
});
},
[imgRef, canvasRef]
);
}
``
The pixelRatio = 2 line matters more than people think. If you output at natural pixel size and the user is on a 3x screen, their avatar looks muddy. At 2x you're getting crisp output for essentially all common cases without a huge file size hit. For profile pictures you probably cap the canvas at 400×400 logical pixels regardless.
One more thing — the rotation transform order is load-bearing. translate → rotate → scale → translate back → drawImage is the right sequence. Swap rotate and scale and you get the wrong pivot point, especially visible on non-square crops. Fun to debug at 1am, I promise.
The toBlob call exports WebP at 0.92 quality, which gives you better compression than JPEG at the same perceived quality. If you need JPEG for legacy backend reasons, swap 'image/webp' for 'image/jpeg' — nothing else changes.
To wire this into your upload flow, call the hook in your parent component and attach it to a "Done" button:
``tsx
const exportCrop = useCropCanvas(imgRef, canvasRef);
async function handleUpload() {
if (!completedCrop) return;
const blob = await exportCrop(completedCrop, scale, rotation);
if (!blob) return;
const formData = new FormData();
formData.append('avatar', blob, 'avatar.webp');
await fetch('/api/upload', { method: 'POST', body: formData });
}
``
Adding Zoom and Rotation Controls
The range inputs in the base component work, but they're ugly and hard to use on touch. A more usable approach is 8px step buttons for rotation and a styled slider for zoom. Worth noting: on mobile, pinch-to-zoom on the crop overlay itself is a better UX than a slider, but that requires hooking into pointer events — out of scope for a quick implementation, but react-image-crop v11 doesn't block touch events, so you can layer it on.
For desktop, a compact control bar is enough:
``tsx
function CropControls({ scale, setScale, rotation, setRotation }) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<button onClick={() => setRotation((r) => r - 90)}>↺ 90°</button>
<button onClick={() => setRotation((r) => r + 90)}>↻ 90°</button>
<label style={{ flex: 1 }}>
<span style={{ fontSize: 12, opacity: 0.7 }}>Zoom {scale.toFixed(1)}x</span>
<input
type="range" min={0.5} max={3} step={0.05}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
style={{ width: '100%' }}
/>
</label>
</div>
);
}
``
In practice, users almost never want fractional rotations from the slider — they want "flip landscape to portrait" style 90° jumps, plus maybe ±5° manual nudges for a tilted photo. Give them dedicated buttons for the common action. The continuous slider is a fallback, not the primary control.
If you want the controls to feel premium, check out what you can do with a custom slider using CSS accent-color or look at how Empire UI handles range inputs in design-system components. A lot of the heavy lifting around consistent hover/focus states is already solved there.
Handling Edge Cases and Aspect Ratios
Avatar uploads almost always need a 1:1 crop. Banner images want 16:9. Product photos want 4:3. Your cropper should accept an aspect prop and enforce it:
``tsx
// Square avatar
<ImageCropper aspect={1} />
// 16:9 banner
<ImageCropper aspect={16 / 9} />
// Free-form (no constraint)
<ImageCropper aspect={undefined} />
``
What happens when the user loads a 100×2000 portrait into a 16:9 cropper? The crop box will initialize to 80% width, which is 80px of a 100px wide image — you'll get a tiny 80×45px output. You should enforce a minimum output size. Add this check before you call canvas.toBlob:
``ts
const MIN_PX = 200; // minimum 200px on shortest side
if (canvas.width < MIN_PX || canvas.height < MIN_PX) {
console.warn('Crop too small — minimum 200px required');
return null;
}
``
File type validation belongs at the input, not at upload time. Filter at the accept attribute and additionally check file.type in your onFileChange handler — some browsers let users rename a .png to .jpg and the accept attribute won't catch it. HEIC from iPhones is the fun one: file.type is 'image/heic' or sometimes an empty string, and canvas.drawImage will throw because browsers don't decode HEIC natively. You either convert server-side or reach for heic2any on the client.
Large files (say, a 20 MB RAW from a mirrorless camera) will lock the main thread while FileReader loads and the canvas renders. Consider using createImageBitmap(file) instead of FileReader — it's async, runs off the main thread in most browsers, and lands the image data without blocking your UI. Just assign the bitmap to img.src after creating an object URL: URL.createObjectURL(file). The createObjectURL approach also skips the base64 encoding that readAsDataURL forces, which is a meaningful memory saving at large file sizes.
Styling the Cropper to Match Your Design System
The default react-image-crop styles are functional but neutral — white handles, grey overlay. You'll want them to match your app. The library exposes CSS custom properties you can override:
``css
/* Override crop overlay colors */
.ReactCrop__crop-selection {
border: 2px solid var(--accent, #6366f1);
}
.ReactCrop__drag-handle::after {
background: var(--accent, #6366f1);
width: 12px;
height: 12px;
}
/* Darken the masked area */
.ReactCrop__mask {
background: rgba(0, 0, 0, 0.55);
}
``
If you're building a glassmorphism-heavy UI, the cropper overlay blends really well with a frosted panel behind it. Check out glassmorphism components for the backdrop-filter patterns, or generate the exact blur/opacity combination you want with the glassmorphism generator. A 16px blur backdrop behind the crop container with a subtle border looks sharp without distracting from the image itself.
For shadow and depth on the container, the box shadow generator is useful — especially if you want a multi-layer shadow that gives the modal a lifted feel without going full skeuomorphic. Worth spending 3 minutes there to get a value you can paste directly into your CSS rather than tweaking box-shadow values by hand.
One more thing — don't forget will-change: transform on the image element inside the crop. With scale() and rotate() active, adding will-change promotes the element to its own compositor layer, which keeps the drag interaction smooth at 60fps on mid-range hardware. Without it, on a 12 MP image you'll see jank at around scale 2.5×.
FAQ
react-image-crop is the most maintained option — it's lightweight, TypeScript-first, and doesn't try to own your canvas export logic. cropperjs with the React wrapper is heavier but ships with more built-in controls if you need them fast.
Use canvas.toBlob() after drawing the crop region with CanvasRenderingContext2D.drawImage(). Wrap it in a Promise and you get a Blob you can append to FormData. See the useCropCanvas hook in this article.
CSS transform handles the visual preview — it's cheap and instant. The canvas export re-applies the same rotation and scale using ctx.rotate() and ctx.scale() so the output pixels actually reflect what the user sees.
Browsers don't natively decode HEIC. Either reject them at the input (check file.type === 'image/heic') and ask users to convert first, or use the heic2any npm package to convert to JPEG on the client before passing to the cropper.