← Filters & retro

Glitch Image

Every few seconds the image has a short fit: slices jump sideways, the red and cyan channels tear apart, the frame hops and blocks of noise flash. A heartbeat later it snaps back as if nothing happened, until the next one.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-glitch-image and give the wrapper a size. The image fills it, cropped to cover.
  3. It works with any image, even from another site.

Options

Add any of these attributes to the same element.

data-intervalAverage time between bursts. Default 2400 ms.
data-intensityHow wild each burst is, from 0 to 2. Default 1.
data-hoverGlitch non-stop while hovered or touched, instead of on a timer.

With reduced motion the image stays clean.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Glitch Image: every few seconds the image has a short fit. Horizontal
    // slices jump sideways, the red and cyan channels tear apart, the frame
    // hops and blocks of noise flash, then it snaps back to normal.
    // Usage: <figure data-glitch-image><img src="photo.jpg" alt="…"></figure>
    // The image (an <img>, or an inline <svg>) fills the box, cropped to cover,
    // and stays in the page for screen readers.
    // Optional: data-interval (average ms between bursts, default 2400),
    // data-intensity (how wild, 0 to 2, default 1), data-hover (glitch
    // non-stop while hovered or touched, instead of on a timer).
    var DEFAULTS = { interval: 2400, intensity: 1 };

    var css =
      '.gim{position:relative;overflow:hidden}' +
      '.gim-src{opacity:0}' +
      '.gim-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Turn the source (<img> or inline <svg>) into something drawImage can use.
    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1400 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    function rnd(a, b) { return a + Math.random() * (b - a); }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var interval = +d.interval || DEFAULTS.interval;
      var amt = d.intensity !== undefined ? +d.intensity : DEFAULTS.intensity;
      var hover = d.hover !== undefined && d.hover !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'gim-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      // The plain image, plus a red-only and a cyan-only copy for tearing the channels.
      var base = document.createElement('canvas');
      var red = document.createElement('canvas');
      var cyan = document.createElement('canvas');
      var w = 0, h = 0, dpr = 1;

      function tint(c, color) {
        c.width = base.width;
        c.height = base.height;
        var x = c.getContext('2d');
        x.drawImage(base, 0, 0);
        x.globalCompositeOperation = 'multiply';
        x.fillStyle = color;
        x.fillRect(0, 0, c.width, c.height);
      }

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return;
        canvas.width = base.width = Math.round(w * dpr);
        canvas.height = base.height = Math.round(h * dpr);
        var b = base.getContext('2d');
        b.imageSmoothingQuality = 'high';
        b.setTransform(dpr, 0, 0, dpr, 0, 0);
        cover(b, img, iw, ih, w, h);
        tint(red, '#f00');
        tint(cyan, '#0ff');
      }

      function plain() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.drawImage(base, 0, 0);
      }

      // One glitched frame: torn channels, shifted slices, a hop and a few noise blocks.
      function glitchFrame(k) {
        var W = canvas.width, H = canvas.height, s = dpr;
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, W, H);
        var hop = Math.random() < 0.3 ? rnd(-6, 6) * s * k : 0;
        var split = rnd(5, 16) * s * k;
        ctx.globalCompositeOperation = 'lighter';
        ctx.drawImage(red, -split, hop);
        ctx.drawImage(cyan, split * 0.6, hop);
        ctx.globalCompositeOperation = 'source-over';
        var n = Math.round(rnd(4, 10) * Math.max(0.5, k));
        for (var i = 0; i < n; i++) {
          var sy = Math.random() * H, sh = rnd(3, Math.max(6, H * 0.16)) * (Math.random() < 0.6 ? 0.4 : 1);
          sh = Math.min(sh, H - sy);
          if (sh < 1) continue;
          var dx = (Math.random() < 0.5 ? -1 : 1) * rnd(0.03, 0.16) * W * k;
          var src = Math.random() < 0.25 ? (Math.random() < 0.5 ? red : cyan) : base;
          ctx.drawImage(src, 0, sy, W, sh, dx, sy, W, sh);
          // Fill the gap the slice leaves with the image wrapped round.
          if (dx > 0) ctx.drawImage(src, W - dx, sy, dx, sh, 0, sy, dx, sh);
          else if (dx < 0) ctx.drawImage(src, 0, sy, -dx, sh, W + dx, sy, -dx, sh);
        }
        var blocks = Math.random() < 0.6 ? Math.round(rnd(1, 4) * k) : 0;
        for (var b = 0; b < blocks; b++) {
          ctx.fillStyle = ['rgba(255,0,90,.55)', 'rgba(0,255,230,.5)', 'rgba(255,255,255,.7)', 'rgba(0,0,0,.45)'][(Math.random() * 4) | 0];
          ctx.fillRect(Math.random() * W, Math.random() * H, rnd(20, 120) * s, rnd(2, 8) * s);
        }
        // A thin bright scan line now and then.
        if (Math.random() < 0.4) {
          ctx.fillStyle = 'rgba(255,255,255,.35)';
          ctx.fillRect(0, Math.random() * H, W, s);
        }
      }

      build();
      if (window.ResizeObserver) new ResizeObserver(function () { build(); plain(); }).observe(el);
      plain();
      if (reduced()) return;

      var visible = false, hovering = false, burstEnd = 0, nextBurst = 0, nextFrame = 0, glitched = false;
      function schedule(now) { nextBurst = now + interval * rnd(0.5, 1.4); }

      if (hover) {
        var on = function () { hovering = true; };
        var off = function () { hovering = false; };
        el.addEventListener('pointerenter', on);
        el.addEventListener('pointerdown', on);
        el.addEventListener('pointerleave', off);
        el.addEventListener('pointercancel', off);
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') off(); });
      }

      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var active;
          if (hover) active = hovering;
          else {
            if (!nextBurst) schedule(now - interval * 0.6);
            if (now >= nextBurst && now > burstEnd) {
              burstEnd = now + rnd(220, 620);
              schedule(burstEnd);
            }
            active = now < burstEnd;
          }
          if (!active) {
            if (glitched) { glitched = false; plain(); }
            return;
          }
          // Change the glitch at an uneven, jerky rate, with the odd clean frame between.
          if (now < nextFrame) return;
          nextFrame = now + rnd(40, 110);
          if (Math.random() < 0.18) { plain(); glitched = false; return; }
          glitchFrame(amt * rnd(0.5, 1.2));
          glitched = true;
        }
      });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) timer.play();
        else { timer.pause(); plain(); glitched = false; }
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-glitch-image]').forEach(function (el) {
        if (el._gim) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._gim = true;
        el.classList.add('gim');
        src.classList.add('gim-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-glitch-image style="height:400px">
  <img src="photo.jpg" alt="Northern lights">
</figure>