Blurring an image in the browser sounds like it should need a library. It doesn't. The Canvas 2D API has a built-in filter property that accepts the same CSS filter syntax you already know — including blur(). This post covers how I built a fully client-side image blur tool in Next.js with blur presets, a custom radius slider, and edge-bleed-free download output.
The live tool: ultimatetools.io/tools/image-tools/blur-image/
The core: ctx.filter = "blur(Xpx)"
The entire blur effect is one line:
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(img, 0, 0);
ctx.filter accepts any CSS filter string. Setting it before drawImage applies the filter to the drawn pixels. The result is the blurred image rendered onto the canvas, ready to export.
This works in all modern browsers and requires zero dependencies.
The edge bleeding problem
There's a catch. When you blur an image and the blur radius is large, the edges of the image fade to transparent — the blur algorithm needs pixels outside the canvas boundary, and there are none, so it blends with transparency (or black).
A 400×300 image blurred at 25px radius will have a soft, faded border on all four sides. That looks wrong when downloaded.
The fix: draw the image oversized, then let the canvas clip it.
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!;
ctx.filter = `blur(${blurRadius}px)`;
// Draw larger than the canvas by blurRadius on each side
const overflow = blurRadius * 2;
ctx.drawImage(
img,
-overflow, // x: start left of canvas
-overflow, // y: start above canvas
img.naturalWidth + overflow * 2, // wider than canvas
img.naturalHeight + overflow * 2 // taller than canvas
);
By drawing the image blurRadius * 2 pixels outside each edge, the blur algorithm has real pixels to work with at the boundary. The canvas clips anything drawn outside its bounds, so the output is full-bleed with no faded edges.
The multiplier * 2 gives enough overflow for the blur kernel at any radius up to 50px. For very high radii you could increase this, but blurRadius * 2 covers the typical range.
Preset system
Three named presets map to fixed blur values:
type Preset = "subtle" | "medium" | "heavy" | "custom";
const PRESETS = [
{ id: "subtle", label: "Subtle", desc: "Light blur", value: 3 },
{ id: "medium", label: "Medium", desc: "Balanced blur", value: 10 },
{ id: "heavy", label: "Heavy", desc: "Strong blur", value: 25 },
];
Selecting a preset sets both the preset state and the blurRadius:
const handlePresetChange = (p: Preset) => {
setPreset(p);
const found = PRESETS.find((x) => x.id === p);
if (found) setBlurRadius(found.value);
};
Moving the custom slider switches to "custom" preset while keeping the numeric radius:
const handleSliderChange = (val: number) => {
setBlurRadius(val);
setPreset("custom");
};
This way the preset buttons correctly deselect when the user drags the slider manually.
Live preview with CSS filter
The download uses Canvas for pixel-accurate output, but the preview uses CSS — it's instant and requires no canvas work:
<img
src={image}
alt="Preview"
style={{ filter: `blur(${blurRadius}px)` }}
/>
CSS filter: blur() on an <img> tag renders the blur in real-time as the slider moves. No re-processing, no canvas — just GPU-accelerated CSS. The preview updates at 60fps while dragging.
The download then uses the canvas approach for edge-correct, full-resolution output.
File loading with FileReader
Images are loaded client-side using FileReader:
const processFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
setFileType(file.type);
setFileName(file.name.replace(/\.[^.]+$/, "")); // strip extension
const reader = new FileReader();
reader.onload = (e) => {
setImage(e.target?.result as string); // base64 data URL
};
reader.readAsDataURL(file);
};
readAsDataURL gives a base64-encoded data URL that can be set directly as img.src. The original file type (image/jpeg, image/png, etc.) is stored so the download uses the same format.
The <img> element is then loaded into a ref for use at download time:
useEffect(() => {
if (!image) return;
const img = new window.Image();
img.onload = () => { imgRef.current = img; };
img.src = image;
}, [image]);
This keeps a reference to the loaded HTMLImageElement with .naturalWidth and .naturalHeight available — needed for setting canvas dimensions at full resolution.
Download
const download = () => {
const img = imgRef.current;
if (!img) return;
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!;
ctx.filter = `blur(${blurRadius}px)`;
const overflow = blurRadius * 2;
ctx.drawImage(
img,
-overflow, -overflow,
img.naturalWidth + overflow * 2,
img.naturalHeight + overflow * 2
);
const ext = fileType === "image/jpeg" ? "jpg" : fileType.split("/")[1];
const link = document.createElement("a");
link.href = canvas.toDataURL(fileType, 0.95);
link.download = `${fileName}-blurred.${ext}`;
link.click();
};
Key points:
- Canvas is sized to
naturalWidth × naturalHeight— full original resolution -
canvas.toDataURL(fileType, 0.95)preserves the original format (JPEG at 95% quality, PNG lossless) - The filename gets a
-blurredsuffix to distinguish from the original - No server involved — the blob URL is created and clicked entirely in the browser
Drag and drop
Standard drag-and-drop with onDragOver, onDragLeave, onDrop:
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files?.[0]) processFile(e.dataTransfer.files[0]);
};
e.preventDefault() on dragover is required — without it the browser handles the drop itself (usually opens the file in a new tab).
Key takeaways
-
ctx.filter = "blur(Xpx)"beforedrawImageis all you need for canvas blur — no library required - Draw the image with
blurRadius * 2overflow on each side to eliminate edge fading - Use CSS
filter: blur()for live preview — it's GPU-accelerated and updates instantly -
FileReader.readAsDataURL→new Image()is the reliable pattern for loading user files into canvas operations - Store the original
fileTypeto preserve format on download
Try it: ultimatetools.io/tools/image-tools/blur-image/ — no upload, runs entirely in your browser.
Top comments (0)