← Filters & retro

Film Grain

Lay a fine, moving grain over an image, a hero or a whole page, like a frame of film. A few frames of noise are made once and shuffled at film speed, blended softly into whatever is underneath.

Golden hour

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-film-grain to any element. The grain goes on top and lets clicks through.

Options

Add any of these attributes to the same element.

data-opacityStrength of the grain, from 0 to 1. Default 0.35.
data-sizeSize of each grain in pixels. Default 1.5.
data-fpsFrames per second. Default 24.
data-blendHow it mixes with the content: any mix-blend-mode. Default overlay; try soft-light or multiply on light designs.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Film Grain: lays a fine, moving grain over a section, like a frame of
    // film. A few frames of noise are made once and shuffled about at film
    // speed, blended softly into whatever is underneath. Paused off screen.
    // Usage: <div data-film-grain>…an image, a hero, anything…</div>
    // Optional: data-opacity (strength, 0 to 1, default 0.35), data-size (grain
    // size in px, default 1.5), data-fps (frames per second, default 24),
    // data-blend (any mix-blend-mode, default "overlay"; try "soft-light" or
    // "multiply" on light designs).
    var DEFAULTS = { opacity: 0.35, size: 1.5, fps: 24, blend: 'overlay' };
    var TILE = 160;   // noise tile size, in grains
    var FRAMES = 6;   // different noise tiles to shuffle

    var css =
      '.fgr{position:relative;overflow:hidden;isolation:isolate}' +
      '.fgr-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // One tile of grey noise, with a little clumping so it reads as grain, not static.
    function tile() {
      var c = document.createElement('canvas');
      c.width = c.height = TILE;
      var g = c.getContext('2d');
      var img = g.createImageData(TILE, TILE), p = img.data;
      for (var i = 0; i < p.length; i += 4) {
        var v = (Math.random() + Math.random() + Math.random()) / 3;   // bell-shaped, mostly mid-grey
        v = 128 + (v - 0.5) * 2 * 150;
        p[i] = p[i + 1] = p[i + 2] = v < 0 ? 0 : v > 255 ? 255 : v;
        p[i + 3] = 255;
      }
      g.putImageData(img, 0, 0);
      return c;
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('fgr');
      var canvas = document.createElement('canvas');
      canvas.className = 'fgr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      canvas.style.opacity = d.opacity !== undefined ? d.opacity : DEFAULTS.opacity;
      canvas.style.mixBlendMode = d.blend || DEFAULTS.blend;
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var size = +d.size || DEFAULTS.size;
      var fps = +d.fps || DEFAULTS.fps;
      var tiles = [];
      for (var i = 0; i < FRAMES; i++) tiles.push(tile());
      var w = 0, h = 0, dpr = 1;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = Math.ceil(w * dpr);
        canvas.height = Math.ceil(h * dpr);
      }

      // Tile a random frame across the canvas from a random offset.
      function draw() {
        var t = tiles[(Math.random() * FRAMES) | 0];
        var s = size * dpr, step = TILE * s;
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.imageSmoothingEnabled = s > 1.6;
        var ox = -Math.random() * step, oy = -Math.random() * step;
        for (var y = oy; y < canvas.height; y += step) {
          for (var x = ox; x < canvas.width; x += step) ctx.drawImage(t, x, y, step, step);
        }
      }

      resize();
      draw();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
      if (reduced()) return;

      var acc = 0, last = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          acc += last ? now - last : 0;
          last = now;
          if (acc < 1000 / fps) return;
          acc = 0;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-film-grain]').forEach(function (el) {
        if (el._fgr) return;
        el._fgr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-film-grain>
  <img src="photo.jpg" alt="…">
</figure>