← Filters & retro

Old Film Reel

Make a section look like a scratchy reel of old film. The picture turns sepia, the lamp flickers, the frame weaves in the gate, and dust, hairs and long scratches flash past at 18 frames a second inside a dark vignette.

Summerat the lake, 1924

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-old-film-reel to a section, image or title card. Its content sits centred in the frame.

Options

Add any of these attributes to the same element.

data-tonesepia (default) or mono for black and white.
data-dustHow much dust and how many scratches, from 0 to 2. Default 1.
data-flickerHow much the light flickers, from 0 to 2. Default 1.
data-fpsFrames per second. Default 18.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Old Film Reel: makes a section look like a scratchy reel of old film.
    // The picture turns sepia, the light flickers, the frame weaves gently in
    // the gate, and dust, hairs and long vertical scratches flash past at 18
    // frames a second, all inside a dark vignette. Paused off screen.
    // Usage: <div data-old-film-reel>…an image, a title card, anything…</div>
    // The content sits centred in the frame.
    // Optional: data-tone ("sepia", the default, or "mono" for black and white),
    // data-dust (amount, 0 to 2, default 1), data-flicker (0 to 2, default 1),
    // data-fps (default 18).
    var DEFAULTS = { tone: 'sepia', dust: 1, flicker: 1, fps: 18 };

    var css =
      '.ofr{position:relative;overflow:hidden;isolation:isolate;display:grid;grid-template:1fr/1fr}' +
      '.ofr-inner{grid-area:1/1;position:relative;display:grid;place-items:center;align-self:stretch;justify-self:stretch;min-width:0;will-change:transform,filter}' +
      '.ofr-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; }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('ofr');
      var inner = document.createElement('div');
      inner.className = 'ofr-inner';
      // The background moves onto the frame, so it tones and weaves with it.
      var cs = getComputedStyle(el);
      inner.style.backgroundColor = cs.backgroundColor;
      inner.style.backgroundImage = cs.backgroundImage;
      inner.style.padding = cs.padding;
      el.style.backgroundImage = 'none';
      el.style.padding = '0';
      while (el.firstChild) inner.appendChild(el.firstChild);
      el.appendChild(inner);
      var canvas = document.createElement('canvas');
      canvas.className = 'ofr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var tone = (d.tone || DEFAULTS.tone) === 'mono' ? 'grayscale(1) contrast(1.15)' : 'sepia(.85) contrast(1.1) saturate(1.1)';
      el._ofr = { inner: inner, canvas: canvas, tone: tone };
      filterFor(el, 1);
    }

    function filterFor(el, b) {
      el._ofr.inner.style.filter = el._ofr.tone + ' brightness(' + b.toFixed(3) + ')';
    }

    function play(el, still) {
      var s = el._ofr, d = el.dataset, canvas = s.canvas;
      var ctx = canvas.getContext('2d');
      var dust = d.dust !== undefined ? +d.dust : DEFAULTS.dust;
      var flicker = d.flicker !== undefined ? +d.flicker : DEFAULTS.flicker;
      var fps = +d.fps || DEFAULTS.fps;
      var R = Math.random;
      var w = 0, h = 0, dpr = 1;
      var scratches = [], hairs = [];

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

      function vignette() {
        var g = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.hypot(w, h) * 0.6);
        g.addColorStop(0, 'rgba(20,12,4,0)');
        g.addColorStop(1, 'rgba(20,12,4,.75)');
        ctx.globalAlpha = 1;
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, w, h);
      }

      function frame() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var area = (w * h) / 100000;

        // Dust: specks and blobs, dark and light, a new set every frame.
        var n = Math.round((2 + R() * 8) * dust * Math.max(0.6, area));
        for (var i = 0; i < n; i++) {
          ctx.globalAlpha = 0.35 + R() * 0.5;
          ctx.fillStyle = R() < 0.7 ? '#140c04' : '#fff8e6';
          ctx.beginPath();
          var r = R() < 0.85 ? 0.6 + R() * 1.6 : 2 + R() * 3;
          ctx.ellipse(R() * w, R() * h, r, r * (0.5 + R() * 0.8), R() * 3, 0, Math.PI * 2);
          ctx.fill();
        }

        // Hairs: curly dark threads that stay for a few frames.
        if (R() < 0.05 * dust) hairs.push({ x: R() * w, y: R() * h, len: 14 + R() * 40, a: R() * 6, life: 2 + ((R() * 8) | 0) });
        ctx.strokeStyle = '#140c04';
        ctx.lineWidth = 1;
        hairs = hairs.filter(function (hr) {
          ctx.globalAlpha = 0.55;
          ctx.beginPath();
          ctx.moveTo(hr.x, hr.y);
          ctx.bezierCurveTo(hr.x + Math.cos(hr.a) * hr.len, hr.y + Math.sin(hr.a) * hr.len,
            hr.x + Math.cos(hr.a + 2) * hr.len, hr.y + Math.sin(hr.a + 2) * hr.len,
            hr.x + Math.cos(hr.a + 1) * hr.len * 1.2, hr.y + Math.sin(hr.a + 1) * hr.len * 1.2);
          ctx.stroke();
          return --hr.life > 0;
        });

        // Scratches: long vertical lines that drift sideways and last a while.
        if (scratches.length < 3 && R() < 0.06 * dust) {
          scratches.push({ x: R() * w, v: (R() - 0.5) * 0.8, life: 10 + ((R() * 50) | 0), light: R() < 0.6, top: R() * 0.3, bot: 0.7 + R() * 0.3 });
        }
        scratches = scratches.filter(function (sc) {
          sc.x += sc.v + (R() - 0.5) * 0.6;
          if (R() < 0.85) {
            ctx.globalAlpha = 0.25 + R() * 0.35;
            ctx.fillStyle = sc.light ? '#fff8e6' : '#140c04';
            ctx.fillRect(sc.x, sc.top * h, R() < 0.3 ? 1.6 : 0.9, (sc.bot - sc.top) * h);
          }
          return --sc.life > 0;
        });

        vignette();

        // The frame weaves in the gate and the lamp flickers.
        var b = 1 - R() * 0.12 * flicker;
        if (R() < 0.03 * flicker) b += 0.18;
        filterFor(el, b);
        var dx = (R() - 0.5) * 1.2, dy = (R() - 0.5) * 1.6;
        if (R() < 0.008) dy += (R() < 0.5 ? -1 : 1) * 8;   // the odd skipped frame
        s.inner.style.transform = 'translate(' + dx.toFixed(2) + 'px,' + dy.toFixed(2) + 'px)';
      }

      resize();
      if (still) {
        // Reduced motion: one frame of dust in its vignette, held.
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        vignette();
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      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;
          frame();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-old-film-reel]').forEach(function (el) {
        if (el._ofr) return;
        setup(el);
        play(el, reduced());
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-old-film-reel>
  <h1>The End</h1>
</section>