Three Canvas Blur Effects Without a Library — Gaussian Convolution, Pixelate, and CSS Backdrop Blur

javascript dev.to

Blur is one of the trickier image effects to implement in the browser because the Canvas API doesn't expose a native Gaussian blur for per-pixel manipulation — only CSS filter: blur() and ctx.filter. Doing it properly on a canvas requires convolution.

Here's how the Image Blur tool at Ultimate Tools implements three distinct blur effects: Gaussian convolution, pixelate, and backdrop blur.

Effect 1: Gaussian Blur via Convolution Kernel

Gaussian blur works by replacing each pixel's value with a weighted average of its neighbors. The weights follow a Gaussian (bell curve) distribution — pixels closer to the center contribute more.

Generating the Kernel

const generateGaussianKernel = (radius: number): number[][] => {
  const size = radius * 2 + 1;
  const sigma = radius / 3;
  const kernel: number[][] = [];
  let sum = 0;

  for (let y = -radius; y <= radius; y++) {
    const row: number[] = [];
    for (let x = -radius; x <= radius; x++) {
      const value = Math.exp(-(x * x + y * y) / (2 * sigma * sigma));
      row.push(value);
      sum += value;
    }
    kernel.push(row);
  }

  // Normalize so all weights sum to 1
  return kernel.map(row => row.map(v => v / sum));
};
Enter fullscreen mode Exit fullscreen mode

Applying the Kernel

const applyGaussianBlur = (imageData: ImageData, radius: number): ImageData => {
  const kernel = generateGaussianKernel(radius);
  const { data, width, height } = imageData;
  const output = new Uint8ClampedArray(data.length);

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let r = 0, g = 0, b = 0, a = 0;

      for (let ky = -radius; ky <= radius; ky++) {
        for (let kx = -radius; kx <= radius; kx++) {
          // Clamp to image boundaries (edge extension)
          const px = Math.min(Math.max(x + kx, 0), width - 1);
          const py = Math.min(Math.max(y + ky, 0), height - 1);
          const pi = (py * width + px) * 4;
          const weight = kernel[ky + radius][kx + radius];

          r += data[pi] * weight;
          g += data[pi + 1] * weight;
          b += data[pi + 2] * weight;
          a += data[pi + 3] * weight;
        }
      }

      const oi = (y * width + x) * 4;
      output[oi] = r;
      output[oi + 1] = g;
      output[oi + 2] = b;
      output[oi + 3] = a;
    }
  }

  return new ImageData(output, width, height);
};
Enter fullscreen mode Exit fullscreen mode

Edge handling: Math.min(Math.max(..., 0), width - 1) clamps coordinates to the image boundary. This "edge extension" avoids a black border that would appear if out-of-bounds pixels were treated as zero.

Performance note: This is O(width × height × radius²). For large images at high radius, it's slow. For radius > 15, the tool switches to ctx.filter for speed — see the optimization below.

Performance Optimization: ctx.filter

For large blur radii where convolution becomes expensive:

const blurViaCtxFilter = (
  srcCanvas: HTMLCanvasElement,
  radius: number
): HTMLCanvasElement => {
  const dst = document.createElement('canvas');
  dst.width = srcCanvas.width;
  dst.height = srcCanvas.height;
  const ctx = dst.getContext('2d')!;

  ctx.filter = `blur(${radius}px)`;
  ctx.drawImage(srcCanvas, 0, 0);
  ctx.filter = 'none';

  return dst;
};
Enter fullscreen mode Exit fullscreen mode

ctx.filter applies a CSS-equivalent Gaussian blur during drawImage. It's GPU-accelerated in modern browsers and handles large radii in milliseconds. The tradeoff: it doesn't expose per-pixel control (can't do selective blur), but for full-image blur it's ideal.

Effect 2: Pixelate

Pixelate divides the image into rectangular blocks and fills each block with the average color of pixels in that area:

const applyPixelate = (imageData: ImageData, blockSize: number): ImageData => {
  const { data, width, height } = imageData;
  const output = new Uint8ClampedArray(data.length);

  for (let y = 0; y < height; y += blockSize) {
    for (let x = 0; x < width; x += blockSize) {
      // Average colors in this block
      let r = 0, g = 0, b = 0, a = 0, count = 0;

      for (let dy = 0; dy < blockSize && y + dy < height; dy++) {
        for (let dx = 0; dx < blockSize && x + dx < width; dx++) {
          const pi = ((y + dy) * width + (x + dx)) * 4;
          r += data[pi];
          g += data[pi + 1];
          b += data[pi + 2];
          a += data[pi + 3];
          count++;
        }
      }

      r = Math.round(r / count);
      g = Math.round(g / count);
      b = Math.round(b / count);
      a = Math.round(a / count);

      // Fill the block with the average color
      for (let dy = 0; dy < blockSize && y + dy < height; dy++) {
        for (let dx = 0; dx < blockSize && x + dx < width; dx++) {
          const oi = ((y + dy) * width + (x + dx)) * 4;
          output[oi] = r;
          output[oi + 1] = g;
          output[oi + 2] = b;
          output[oi + 3] = a;
        }
      }
    }
  }

  return new ImageData(output, width, height);
};
Enter fullscreen mode Exit fullscreen mode

This is O(width × height) regardless of block size — each pixel is read once and written once.

Effect 3: Backdrop (Heavy) Blur

Backdrop blur is a heavy Gaussian blur at a large radius (40–80px) — the image resolves into abstract color fields with no recognizable detail. The implementation uses ctx.filter at high blur values:

const applyBackdropBlur = (srcCanvas: HTMLCanvasElement, intensity: number): HTMLCanvasElement => {
  const radius = 20 + intensity * 3; // intensity 0-20 → radius 20-80px
  return blurViaCtxFilter(srcCanvas, radius);
};
Enter fullscreen mode Exit fullscreen mode

At radius 60+, faces, text, and shapes are completely unrecognizable. This makes it useful for background generation and for creating "frosted glass" effects.

Reading and Writing Image Data

All three effects use the same pipeline:

const applyEffect = async (
  file: File,
  effect: 'gaussian' | 'pixelate' | 'backdrop',
  intensity: number
): Promise<string> => {
  const bitmap = await createImageBitmap(file);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  const ctx = canvas.getContext('2d')!;
  ctx.drawImage(bitmap, 0, 0);

  if (effect === 'gaussian') {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const blurred = applyGaussianBlur(imageData, intensity);
    ctx.putImageData(blurred, 0, 0);
  } else if (effect === 'pixelate') {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixelated = applyPixelate(imageData, intensity * 2);
    ctx.putImageData(pixelated, 0, 0);
  } else {
    return applyBackdropBlur(canvas, intensity).toDataURL('image/jpeg', 0.92);
  }

  return canvas.toDataURL('image/jpeg', 0.92);
};
Enter fullscreen mode Exit fullscreen mode

createImageBitmap handles JPEG, PNG, and WebP decoding in a single call.

Key Comparison

Effect Approach Complexity Use Case
Gaussian Convolution kernel O(n × r²) Natural softening
Pixelate Block averaging O(n) Censoring/anonymizing
Backdrop ctx.filter at high radius GPU-accelerated Abstract backgrounds

The three effects cover the main use cases for browser-based blur — smooth, censor, and abstract. The full tool is live at Image Blur.

Source: dev.to

arrow_back Back to Tutorials