Skip to content

Grain Overlay

A subtle animated film-grain texture layered over any section via canvas.

Freeeditorialbrutalist
Category
Backgrounds
Added
2026-08-21
Deps
none
Updated
2026-09-01
Preview
Texture, not decoration

Props

0.08
3
Animate (vs static)
24
No runtime dependencies
"use client";

import { useEffect, useRef } from "react";

/**
 * SIZED BY ITS PARENT — the parent needs a height.
 *
 * This fills the box it is given: the canvas is absolutely positioned, so
 * the wrapper has nothing in normal flow to derive a height from. Drop it
 * into a plain section or a hero with no height set and it lays out at zero
 * and renders nothing — no error, no warning, no console message, just an
 * empty rectangle, which is the hardest kind of failure to go looking for.
 *
 * Give the parent a real height (h-screen, h-[520px], an aspect-ratio, a
 * grid track) or position this against one that already has one.
 */
export function GrainOverlay({
  density = 3,
  opacity = 0.08,
  animated = true,
  fps = 24,
}: {
  density?: number;
  opacity?: number;
  /** Grain re-roll rate. Film is 24; the display's 60 is harsher and costlier. */
  fps?: number;
  /** Re-roll the noise every frame. Off paints one static field and stops. */
  animated?: boolean;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // Measure via ResizeObserver, not a one-time offsetWidth read — in a
    // scaled thumbnail the canvas can still be 0x0 on first paint, and
    // createImageData(0, 0) throws.
    let width = 0;
    let height = 0;
    let raf = 0;

    const observer = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      width = Math.max(0, Math.round(entry.contentRect.width));
      height = Math.max(0, Math.round(entry.contentRect.height));
      canvas.width = width;
      canvas.height = height;
      // Assigning width CLEARS the bitmap, and with animation off there is no
      // loop to put it back — a static grain would sit blank from the first
      // resize onward. It also covers the very first measurement, where the
      // canvas is still 0x0 when the initial paint runs.
      paint();
    });
    observer.observe(canvas);

    // GRAIN HAS A FRAME RATE, and it is not the display's. Real film runs at
    // 24, and re-rolling every pixel 60 times a second is both harsher than
    // any photochemical grain and two and a half times the work. The loop
    // still runs every frame — it just only REDRAWS when enough time has
    // passed, so the cost scales with the chosen rate rather than the monitor.
    let lastDraw = 0;
    const minGap = fps > 0 ? 1000 / fps : 0;

    function paint(now?: number) {
      // A call with no timestamp is a DIRECT one — the first paint, or a
      // repaint after a resize — and it always draws. Only the rAF-driven
      // calls, which arrive with a timestamp, are rate-gated. Without this the
      // static mode never paints at all, because there is no loop behind it to
      // try again.
      const stamp = now === undefined ? lastDraw : now;
      const due = now === undefined || minGap === 0 || stamp - lastDraw >= minGap;
      if (due && width > 0 && height > 0) {
        lastDraw = stamp;
        const imageData = ctx!.createImageData(width, height);
        const buffer = imageData.data;
        //
        // THE STEP MUST BE AT LEAST ONE WHOLE PIXEL.
        //
        // density is an ordinary number prop, so 0 is a value someone can
        // pass — from a quality slider, a CMS field, a parsed query string.
        // At 0 this reads i += 0: a for-loop that never advances and never
        // exits. That is not a slow frame, it is a permanently hung tab that
        // has to be force-killed. The min in the schema constrains this
        // demo's slider, NOT the component anyone copies out of here.
        const step = 4 * Math.max(1, Math.round(Number(density) || 1));
        for (let i = 0; i < buffer.length; i += step) {
          const shade = Math.random() * 255;
          buffer[i] = shade;
          buffer[i + 1] = shade;
          buffer[i + 2] = shade;
          buffer[i + 3] = 255;
        }
        ctx!.putImageData(imageData, 0, 0);
      }
      if (animated) raf = requestAnimationFrame(paint);
    }
    paint();

    return () => {
      observer.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [density, animated, fps]);

  return (
    <canvas
      ref={canvasRef}
      className="pointer-events-none absolute inset-0 h-full w-full mix-blend-overlay"
      style={{ opacity }}
    />
  );
}
Notes

About this background

Grain here is genuine per-pixel randomness drawn to a canvas every animation frame, not a tiled noise PNG or an SVG feTurbulence filter — canvas ImageData gives true random luminance per pixel with no visible repeat, at the cost of a real CPU hit for the redraw loop. Reach for it over a static noise background when the section is dark and hero-scale and you want the grain to actually shimmer; reach for a pre-rendered noise image or an SVG filter instead when the effect repeats across many small elements on one page, like a card grid, where a rAF loop per instance adds up. The non-obvious part: canvas size comes from a ResizeObserver on the canvas element rather than a one-time offsetWidth read, because a canvas inside a scaled thumbnail — this library's own preview cards — can genuinely measure 0x0 on first paint, and createImageData(0, 0) throws; every paint call is guarded to skip while either dimension is still zero. Density, animation and the grain's frame rate are all real props on the shipped component, not preview-only controls. The rate is the one worth setting deliberately: film grain runs at twenty-four frames a second, and re-rolling every pixel at the display's sixty is both harsher than any photochemical grain and two and a half times the work for it.

Curator’s note

mix-blend-overlay is what sells it — flat opacity alone looks like dust on the lens, blend-overlay makes it feel baked into the surface.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
New

A seamless procedural concrete surface drawn once to canvas and blended over anything.

textureconcretecanvas
Featured
NewPro

A survey map of terrain that never existed, traced live with marching squares and drifting underfoot.

canvasnoisecontour
Pro

A slow-drifting colored noise wash rendered at low resolution and upscaled smooth.

noisegradientcanvas