Image editing features add value to any content platform. Here is how to build a crop and filter editor using the Canvas API.
Install react-image-crop
pnpm add react-image-crop
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 {
src: string;
onCropComplete: (blob: Blob) => void;
aspect?: number;
}
export function ImageCropper({ src, onCropComplete, aspect }: ImageCropperProps) {
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imgRef = useRef<HTMLImageElement>(null);
const handleCropImage = useCallback(async () => {
if (!completedCrop || !imgRef.current) return;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const image = imgRef.current;
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = completedCrop.width * scaleX;
canvas.height = completedCrop.height * scaleY;
ctx.drawImage(
image,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0,
0,
canvas.width,
canvas.height
);
canvas.toBlob(
(blob) => {
if (blob) onCropComplete(blob);
},
"image/jpeg",
0.9
);
}, [completedCrop, onCropComplete]);
return (
<div className="space-y-4">
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompletedCrop(c)}
aspect={aspect}
className="max-h-[500px]"
>
<img
ref={imgRef}
src={src}
alt="Crop preview"
className="max-w-full"
crossOrigin="anonymous"
/>
</ReactCrop>
<div className="flex gap-2">
{[
{ label: "Free", value: undefined },
{ label: "1:1", value: 1 },
{ label: "16:9", value: 16 / 9 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:2", value: 3 / 2 },
].map(({ label, value }) => (
<button
key={label}
onClick={() => {
setCrop(undefined);
// Aspect is controlled via the aspect prop
}}
className="px-3 py-1 text-xs border rounded hover:bg-muted"
>
{label}
</button>
))}
</div>
<button
onClick={handleCropImage}
disabled={!completedCrop}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm disabled:opacity-50"
>
Apply Crop
</button>
</div>
);
}
Canvas-Based Image Editor
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
interface ImageEditorProps {
src: string;
onSave: (blob: Blob) => void;
}
interface Adjustments {
brightness: number;
contrast: number;
saturation: number;
rotation: number;
flipH: boolean;
flipV: boolean;
}
const DEFAULT_ADJUSTMENTS: Adjustments = {
brightness: 100,
contrast: 100,
saturation: 100,
rotation: 0,
flipH: false,
flipV: false,
};
export function ImageEditor({ src, onSave }: ImageEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [adjustments, setAdjustments] = useState<Adjustments>(DEFAULT_ADJUSTMENTS);
const [zoom, setZoom] = useState(1);
// Load image
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageRef.current = img;
renderCanvas();
};
img.src = src;
}, [src]);
const renderCanvas = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const img = imageRef.current;
if (!canvas || !ctx || !img) return;
const { rotation, flipH, flipV, brightness, contrast, saturation } = adjustments;
const isRotated = rotation === 90 || rotation === 270;
canvas.width = isRotated ? img.naturalHeight : img.naturalWidth;
canvas.height = isRotated ? img.naturalWidth : img.naturalHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
// Apply transforms
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.scale(zoom, zoom);
// Apply CSS filters
ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`;
ctx.drawImage(
img,
-img.naturalWidth / 2,
-img.naturalHeight / 2,
img.naturalWidth,
img.naturalHeight
);
ctx.restore();
}, [adjustments, zoom]);
useEffect(() => {
renderCanvas();
}, [renderCanvas]);
const updateAdjustment = <K extends keyof Adjustments>(
key: K,
value: Adjustments[K]
) => {
setAdjustments((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.toBlob(
(blob) => {
if (blob) onSave(blob);
},
"image/jpeg",
0.9
);
};
const handleReset = () => {
setAdjustments(DEFAULT_ADJUSTMENTS);
setZoom(1);
};
return (
<div className="flex gap-6">
{/* Canvas Preview */}
<div className="flex-1 bg-muted/30 rounded-lg flex items-center justify-center overflow-hidden p-4">
<canvas
ref={canvasRef}
className="max-w-full max-h-[500px] object-contain"
/>
</div>
{/* Controls */}
<div className="w-64 space-y-5">
<h3 className="font-semibold text-sm">Adjustments</h3>
<SliderControl
label="Brightness"
value={adjustments.brightness}
min={0}
max={200}
onChange={(v) => updateAdjustment("brightness", v)}
/>
<SliderControl
label="Contrast"
value={adjustments.contrast}
min={0}
max={200}
onChange={(v) => updateAdjustment("contrast", v)}
/>
<SliderControl
label="Saturation"
value={adjustments.saturation}
min={0}
max={200}
onChange={(v) => updateAdjustment("saturation", v)}
/>
<SliderControl
label="Zoom"
value={zoom * 100}
min={25}
max={300}
onChange={(v) => setZoom(v / 100)}
/>
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-2">
Transform
</h4>
<div className="flex flex-wrap gap-1">
<button
onClick={() =>
updateAdjustment("rotation", (adjustments.rotation + 90) % 360)
}
className="px-2 py-1 text-xs border rounded hover:bg-muted"
>
Rotate 90
</button>
<button
onClick={() => updateAdjustment("flipH", !adjustments.flipH)}
className={`px-2 py-1 text-xs border rounded ${
adjustments.flipH ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
>
Flip H
</button>
<button
onClick={() => updateAdjustment("flipV", !adjustments.flipV)}
className={`px-2 py-1 text-xs border rounded ${
adjustments.flipV ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
>
Flip V
</button>
</div>
</div>
<div className="flex gap-2 pt-4 border-t">
<button
onClick={handleReset}
className="flex-1 px-3 py-2 text-sm border rounded hover:bg-muted"
>
Reset
</button>
<button
onClick={handleSave}
className="flex-1 px-3 py-2 text-sm bg-primary text-primary-foreground rounded"
>
Save
</button>
</div>
</div>
</div>
);
}
function SliderControl({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
}) {
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span>{label}</span>
<span className="text-muted-foreground">{Math.round(value)}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
Usage
"use client";
import { useState } from "react";
import { ImageEditor } from "@/components/ImageEditor";
export function AvatarUpload() {
const [image, setImage] = useState<string | null>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(URL.createObjectURL(file));
}
};
const handleSave = async (blob: Blob) => {
const formData = new FormData();
formData.append("avatar", blob, "avatar.jpg");
await fetch("/api/upload/avatar", { method: "POST", body: formData });
setImage(null);
};
return (
<div>
{image ? (
<ImageEditor src={image} onSave={handleSave} />
) : (
<label className="block border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50">
<input type="file" accept="image/*" onChange={handleFileSelect} className="hidden" />
<p className="text-sm text-muted-foreground">Click to upload an image</p>
</label>
)}
</div>
);
}
Need Image Editing Features?
We build custom media tools and image processing pipelines. Contact us to discuss your project.