← Filters & retro

VHS Tape

Play a section like a worn videotape. Tracking noise rolls down the picture, colours bleed red and cyan, the image wobbles and jumps now and then, and head-switching noise frays the bottom edge. A “PLAY” label and a running tape counter finish it off.

Summer
’89

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-vhs-tape to a section. The noise is drawn over your content, and the content itself wobbles.

Options

Add any of these attributes to the same element.

data-labelThe corner text. Default “PLAY ▶”. data-label="" hides the label and the counter.
data-bleedPixels of red and cyan colour bleed. Default 2; 0 turns it off.
data-wobbleHow much the picture sways and jumps, from 0 to 2. Default 1.
data-speedHow fast the tracking band rolls. Default 1.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // VHS Tape: plays a section like a worn videotape. Tracking noise rolls down
    // the picture, colours bleed red and cyan at the edges, the image wobbles
    // and jumps now and then, and a strip of head-switching noise frays the
    // bottom. A "PLAY" label and a running tape counter sit in the corners.
    // The noise is drawn on a canvas over your content and pauses off screen.
    // Usage: <div data-vhs-tape style="background:#1e1b4b;color:#fff">…</div>
    // Optional: data-label (corner text, default "PLAY ▶"; data-label="" hides
    // the label and counter), data-bleed (px of colour bleed, default 2),
    // data-wobble (0 to 2, default 1), data-speed (tracking roll speed, default 1).
    var DEFAULTS = { label: 'PLAY ▶', bleed: 2, wobble: 1, speed: 1 };

    var css =
      '.vhs{position:relative;overflow:hidden;isolation:isolate}' +
      '.vhs-inner{position:relative;will-change:transform,filter}' +
      '.vhs-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2}' +
      '.vhs-osd{position:absolute;z-index:3;pointer-events:none;font:700 clamp(11px,2.6vw,18px)/1 ui-monospace,Menlo,Consolas,monospace;' +
        'letter-spacing:.08em;color:#fff;text-shadow:2px 0 rgba(255,0,80,.7),-2px 0 rgba(0,220,255,.7)}' +
      '.vhs-label{left:5%;top:7%}' +
      '.vhs-count{left:5%;bottom:9%;font-weight:600}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function pad(n) { return (n < 10 ? '0' : '') + n; }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('vhs');
      // Wrap the content, so it can wobble and bleed without moving the noise.
      var inner = document.createElement('div');
      inner.className = 'vhs-inner';
      while (el.firstChild) inner.appendChild(el.firstChild);
      el.appendChild(inner);
      var bleed = d.bleed !== undefined ? +d.bleed : DEFAULTS.bleed;
      if (bleed) {
        inner.style.filter = 'drop-shadow(' + bleed + 'px 0 0 rgba(255,0,60,.55)) drop-shadow(' + -bleed + 'px 0 0 rgba(0,210,255,.55))';
      }

      var canvas = document.createElement('canvas');
      canvas.className = 'vhs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);

      var label = d.label !== undefined ? d.label : DEFAULTS.label;
      var count = null;
      if (label) {
        var l = document.createElement('div');
        l.className = 'vhs-osd vhs-label';
        l.setAttribute('aria-hidden', 'true');
        l.textContent = label;
        el.appendChild(l);
        count = document.createElement('div');
        count.className = 'vhs-osd vhs-count';
        count.setAttribute('aria-hidden', 'true');
        count.textContent = 'SP 0:00:00';
        el.appendChild(count);
      }
      el._vhs = { inner: inner, canvas: canvas, count: count };
    }

    function play(el) {
      var s = el._vhs, d = el.dataset;
      var ctx = s.canvas.getContext('2d');
      var wobble = d.wobble !== undefined ? +d.wobble : DEFAULTS.wobble;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var w = 0, h = 0, dpr = 1, t = 0, band = 0.2, jump = 0, sec = -1;

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

      // Short bright dashes: the snow of a tracking error.
      function dashes(y0, y1, density, alpha) {
        var n = Math.round(w * (y1 - y0) * density / 400);
        for (var i = 0; i < n; i++) {
          var y = y0 + Math.random() * (y1 - y0);
          ctx.globalAlpha = alpha * Math.random();
          ctx.fillRect(Math.random() * w, y, 4 + Math.random() * 46, 1 + Math.random() * 1.5);
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = '#fff';

        // The tracking band: a soft light bar full of noise, rolling downwards.
        var bh = Math.max(18, h * 0.12);
        var by = band * (h + bh * 2) - bh;
        var g = ctx.createLinearGradient(0, by - bh / 2, 0, by + bh / 2);
        g.addColorStop(0, 'rgba(255,255,255,0)');
        g.addColorStop(0.5, 'rgba(255,255,255,.09)');
        g.addColorStop(1, 'rgba(255,255,255,0)');
        ctx.globalAlpha = 1;
        ctx.fillStyle = g;
        ctx.fillRect(0, by - bh / 2, w, bh);
        ctx.fillStyle = '#fff';
        dashes(by - bh * 0.4, by + bh * 0.4, 5, 0.75);
        // A thin hard line at its leading edge.
        ctx.globalAlpha = 0.25;
        ctx.fillRect(0, by + bh * 0.3, w, 1);

        // Head-switching noise along the bottom edge.
        var hs = Math.max(6, h * 0.035);
        dashes(h - hs, h, 9, 0.8);
        ctx.globalAlpha = 0.18;
        ctx.fillRect(0, h - hs, w, hs);

        // Faint snow all over, and soft dark lines.
        dashes(0, h, 0.12, 0.35);
        ctx.globalAlpha = 0.12;
        ctx.fillStyle = '#000';
        for (var y = 0; y < h; y += 3) ctx.fillRect(0, y, w, 1);
        ctx.globalAlpha = 1;
      }

      function moveContent() {
        // Wobble: a slow sway plus jitter. Now and then the picture jumps.
        if (Math.random() < 0.004 * wobble) jump = 1;
        jump *= 0.85;
        var x = (Math.sin(t / 900) * 0.8 + (Math.random() - 0.5) * 0.9) * wobble + jump * 10 * wobble;
        var y = jump * -6 * wobble;
        s.inner.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) skewX(' + (jump * -4 * wobble).toFixed(2) + 'deg)';
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      var last = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          t += dt;
          band = (band + dt * 0.00011 * speed) % 1;
          moveContent();
          draw();
          if (s.count && Math.floor(t / 1000) !== sec) {
            sec = Math.floor(t / 1000);
            s.count.textContent = 'SP ' + Math.floor(sec / 3600) + ':' + pad(Math.floor(sec / 60) % 60) + ':' + pad(sec % 60);
          }
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function still(el) {
      // Reduced motion: keep the colour bleed and labels, draw the scanlines once.
      var s = el._vhs, ctx = s.canvas.getContext('2d');
      var dpr = Math.min(2, window.devicePixelRatio || 1), w = el.clientWidth, h = el.clientHeight;
      s.canvas.width = w * dpr;
      s.canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.globalAlpha = 0.12;
      for (var y = 0; y < h; y += 3) ctx.fillRect(0, y, w, 1);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-vhs-tape]').forEach(function (el) {
        if (el._vhs) return;
        setup(el);
        if (reduced()) still(el);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-vhs-tape>
  <h1>Summer '89</h1>
</section>