Image cropping is essential for profile pictures, thumbnails, and content images. Here is how to build it.
Step 1: Install Dependencies
pnpm add react-image-crop
Step 2: Image Cropper Component
"use client";
import { useState, useRef, useCallback } from "react";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
interface ImageCropperProps {
onCropComplete: (blob: Blob) => void;
aspectRatio?: number;
maxWidth?: number;
maxHeight?: number;
circularCrop?: boolean;
}
export function ImageCropper({
onCropComplete,
aspectRatio,
maxWidth = 800,
maxHeight = 800,
circularCrop = false,
}: ImageCropperProps) {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imgRef = useRef<HTMLImageElement>(null);
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith("image/")) {
alert("Please select an image file");
return;
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
alert("File size must be under 10MB");
return;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
setImageSrc(reader.result as string);
});
reader.readAsDataURL(file);
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget;
// Default crop centered at 50%
const cropWidth = Math.min(width * 0.8, maxWidth);
const cropHeight = aspectRatio ? cropWidth / aspectRatio : Math.min(height * 0.8, maxHeight);
setCrop({
unit: "px",
x: (width - cropWidth) / 2,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
});
}
const getCroppedImage = useCallback(async () => {
const image = imgRef.current;
if (!image || !completedCrop) return;
const canvas = document.createElement("canvas");
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const outputWidth = Math.min(completedCrop.width * scaleX, maxWidth);
const outputHeight = Math.min(completedCrop.height * scaleY, maxHeight);
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (circularCrop) {
ctx.beginPath();
ctx.arc(outputWidth / 2, outputHeight / 2, Math.min(outputWidth, outputHeight) / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
}
ctx.drawImage(
image,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0,
0,
outputWidth,
outputHeight
);
canvas.toBlob(
(blob) => {
if (blob) onCropComplete(blob);
},
"image/jpeg",
0.9
);
}, [completedCrop, circularCrop, maxWidth, maxHeight, onCropComplete]);
return (
<div className="space-y-4">
{!imageSrc ? (
<label className="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-colors hover:border-blue-500 dark:border-gray-700">
<p className="mb-2 text-sm font-medium">Click to select an image</p>
<p className="text-xs text-gray-400">JPG, PNG, or WebP. Max 10MB.</p>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onSelectFile}
className="hidden"
/>
</label>
) : (
<>
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompletedCrop(c)}
aspect={aspectRatio}
circularCrop={circularCrop}
>
<img
ref={imgRef}
src={imageSrc}
alt="Crop preview"
onLoad={onImageLoad}
className="max-h-[500px] w-auto"
/>
</ReactCrop>
<div className="flex gap-2">
<button
onClick={() => {
setImageSrc(null);
setCrop(undefined);
setCompletedCrop(undefined);
}}
className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-700"
>
Change Image
</button>
<button
onClick={getCroppedImage}
disabled={!completedCrop}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Apply Crop
</button>
</div>
</>
)}
</div>
);
}
Step 3: Avatar Upload with Crop
"use client";
import { useState } from "react";
import { ImageCropper } from "./ImageCropper";
import { Camera } from "lucide-react";
export function AvatarUpload({ currentAvatar }: { currentAvatar?: string }) {
const [showCropper, setShowCropper] = useState(false);
const [preview, setPreview] = useState<string | null>(currentAvatar ?? null);
const [isUploading, setIsUploading] = useState(false);
async function handleCropComplete(blob: Blob) {
// Show preview immediately
const url = URL.createObjectURL(blob);
setPreview(url);
setShowCropper(false);
// Upload to server
setIsUploading(true);
const formData = new FormData();
formData.append("avatar", blob, "avatar.jpg");
try {
const res = await fetch("/api/upload/avatar", {
method: "POST",
body: formData,
});
const { url: uploadedUrl } = await res.json();
setPreview(uploadedUrl);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setIsUploading(false);
}
}
return (
<div>
{showCropper ? (
<div className="max-w-lg">
<ImageCropper
onCropComplete={handleCropComplete}
aspectRatio={1}
maxWidth={400}
maxHeight={400}
circularCrop
/>
</div>
) : (
<div className="flex items-center gap-4">
<div className="relative">
<div className="h-20 w-20 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
{preview ? (
<img src={preview} alt="Avatar" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-2xl font-bold text-gray-400">
?
</div>
)}
</div>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</div>
<button
onClick={() => setShowCropper(true)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<Camera className="h-4 w-4" />
Change Photo
</button>
</div>
)}
</div>
);
}
Step 4: Batch Image Resizer
"use client";
import { useState, useCallback } from "react";
interface ResizePreset {
name: string;
width: number;
height: number;
}
const PRESETS: ResizePreset[] = [
{ name: "Thumbnail", width: 150, height: 150 },
{ name: "Small", width: 320, height: 240 },
{ name: "Medium", width: 640, height: 480 },
{ name: "Large", width: 1280, height: 960 },
{ name: "Social (1200x630)", width: 1200, height: 630 },
{ name: "Instagram (1080x1080)", width: 1080, height: 1080 },
];
function resizeImage(
file: File,
maxWidth: number,
maxHeight: number,
quality: number = 0.85
): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width *= ratio;
height *= ratio;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error("Could not create blob"));
},
file.type === "image/png" ? "image/png" : "image/jpeg",
quality
);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
export function BatchResizer() {
const [files, setFiles] = useState<File[]>([]);
const [selectedPreset, setSelectedPreset] = useState<ResizePreset>(PRESETS[2]);
const [quality, setQuality] = useState(85);
const [results, setResults] = useState<{ name: string; blob: Blob; size: number }[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const processImages = useCallback(async () => {
setIsProcessing(true);
const processed: typeof results = [];
for (const file of files) {
const blob = await resizeImage(
file,
selectedPreset.width,
selectedPreset.height,
quality / 100
);
processed.push({ name: file.name, blob, size: blob.size });
}
setResults(processed);
setIsProcessing(false);
}, [files, selectedPreset, quality]);
function downloadAll() {
results.forEach(({ name, blob }) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `resized-${name}`;
a.click();
URL.revokeObjectURL(url);
});
}
return (
<div className="space-y-4">
<input
type="file"
accept="image/*"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
className="block w-full text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-blue-600 file:px-4 file:py-2 file:text-white"
/>
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => setSelectedPreset(preset)}
className={`rounded-lg px-3 py-1.5 text-xs ${
selectedPreset.name === preset.name
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-gray-800"
}`}
>
{preset.name} ({preset.width}x{preset.height})
</button>
))}
</div>
<div className="flex items-center gap-3">
<label className="text-sm">Quality: {quality}%</label>
<input
type="range"
min={10}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="flex-1"
/>
</div>
<button
onClick={processImages}
disabled={files.length === 0 || isProcessing}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{isProcessing ? "Processing..." : `Resize ${files.length} Image${files.length !== 1 ? "s" : ""}`}
</button>
{results.length > 0 && (
<div>
<div className="mb-2 flex justify-between">
<span className="text-sm font-medium">{results.length} images processed</span>
<button onClick={downloadAll} className="text-sm text-blue-600 hover:underline">
Download All
</button>
</div>
<div className="space-y-1">
{results.map((r) => (
<div key={r.name} className="flex items-center justify-between text-sm">
<span className="truncate">{r.name}</span>
<span className="text-gray-400">{(r.size / 1024).toFixed(0)} KB</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
Summary
- react-image-crop provides interactive cropping with aspect ratio constraints
- Canvas API handles resizing and format conversion
- Circular crop mode for avatar uploads
- Batch processing with quality control and presets
Need Image Management Features?
We build media management systems with upload, crop, and optimization workflows. Contact us to get started.