← Filters & retro

RGB Split Hover

Point at an image and its red, green and blue channels slide apart, like a projector knocked out of line. The colours lean away from the pointer, further the nearer you get to the edge, shiver slightly while split, and snap back together when you leave.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-rgb-split-hover and give the wrapper a size. The image fills it, cropped to cover.
  3. The channels are split with canvas blending, so any image works, even from another site.

Options

Add any of these attributes to the same element.

data-amountHow far the channels part, in pixels. Default 18.
data-jitterA nervous shake while split, from 0 to 1. Default 0.3; 0 keeps it still.
data-autoplaySplits and rejoins by itself while nobody is pointing. Handy on phones.

Touch works too: press or drag on the picture. The original image stays in the page for screen readers.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // RGB Split Hover: an image's red, green and blue channels slide apart when
    // the pointer is over it, leaning away from the pointer like a misaligned
    // projector, and snap back together when it leaves. Touch works too.
    // Usage: <figure data-rgb-split-hover><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-amount (how far the channels part, px, default 18),
    // data-jitter (a nervous shake while split, 0 to 1, default 0.3),
    // data-autoplay (splits and rejoins by itself while nobody is pointing).
    var DEFAULTS = { amount: 18, jitter: 0.3 };

    var css =
      '.rsh{position:relative;overflow:hidden}' +
      '.rsh-src{opacity:0}' +
      '.rsh-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); });
      }
    }

    // Draw the image to fill w × h, cropping like object-fit: cover.
    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 setup(el, img, iw, ih) {
      var d = el.dataset;
      var amount = d.amount !== undefined ? +d.amount : DEFAULTS.amount;
      var jitter = d.jitter !== undefined ? +d.jitter : DEFAULTS.jitter;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'rsh-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      // One canvas per channel, a margin wider than the image so shifts never show an edge.
      var chans = [0, 1, 2].map(function () { return document.createElement('canvas'); });
      var tints = ['#f00', '#0f0', '#00f'];
      var w = 0, h = 0, dpr = 1, m = 0;

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return;
        m = Math.ceil(amount * 1.6) + 4;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        chans.forEach(function (c, i) {
          c.width = Math.round((w + 2 * m) * dpr);
          c.height = Math.round((h + 2 * m) * dpr);
          var x = c.getContext('2d');
          x.setTransform(dpr, 0, 0, dpr, 0, 0);
          cover(x, img, iw, ih, w + 2 * m, h + 2 * m);
          // Multiplying by pure red keeps only the red channel, and so on.
          x.globalCompositeOperation = 'multiply';
          x.fillStyle = tints[i];
          x.fillRect(0, 0, w + 2 * m, h + 2 * m);
          x.globalCompositeOperation = 'source-over';
        });
      }

      // Red and blue move in opposite directions; green stays put. Added back
      // together with "lighter", three aligned channels give the original image.
      function draw(ox, oy) {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        ctx.drawImage(chans[0], -m - ox, -m - oy, w + 2 * m, h + 2 * m);
        ctx.drawImage(chans[1], -m, -m, w + 2 * m, h + 2 * m);
        ctx.drawImage(chans[2], -m + ox, -m + oy, w + 2 * m, h + 2 * m);
        ctx.globalCompositeOperation = 'source-over';
      }

      build();
      if (window.ResizeObserver) new ResizeObserver(function () { build(); draw(0, 0); }).observe(el);
      if (reduced()) { draw(amount * 0.35, 0); return; }
      draw(0, 0);

      var real = null, lastReal = -1e9, t = 0;
      var p = { x: 0, y: 0, k: 0 };
      function move(e) {
        var r = el.getBoundingClientRect();
        real = { x: e.clientX - r.left, y: e.clientY - r.top };
        lastReal = t;
      }
      el.addEventListener('pointermove', move);
      el.addEventListener('pointerdown', move);
      el.addEventListener('pointerleave', function () { real = null; lastReal = t; });
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') { real = null; lastReal = t; } });

      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;
          var target = null, on = 0;
          if (real) { target = real; on = 1; }
          else if (autoplay && t - lastReal > 2200) {
            // A pretend pointer wanders, hovering for a while and then leaving.
            target = { x: w * (0.5 + 0.38 * Math.sin(t / 1300)), y: h * (0.5 + 0.3 * Math.sin(t / 900 + 1)) };
            on = Math.sin(t / 1400) > -0.25 ? 1 : 0;
          }
          var e = 1 - Math.pow(0.86, dt / 16);
          if (target) {
            p.x += (target.x - p.x) * e;
            p.y += (target.y - p.y) * e;
          }
          p.k += (on - p.k) * e * 0.7;
          if (p.k < 0.002 && !on) { if (p.k !== 0) { p.k = 0; draw(0, 0); } return; }
          // Lean away from the pointer: further from the centre, further apart.
          var nx = w ? Math.max(-1, Math.min(1, (p.x - w / 2) / (w / 2))) : 0;
          var ny = h ? Math.max(-1, Math.min(1, (p.y - h / 2) / (h / 2))) : 0;
          // Narrow boxes (phones) get a smaller split.
          var a = amount * Math.max(0.5, Math.min(1, w / 700));
          var ox = a * (0.55 * (nx < 0 ? -1 : 1) + 0.45 * nx);
          var oy = a * 0.5 * ny;
          var j = jitter * a * 0.35;
          if (j && Math.random() < 0.35) { ox += (Math.random() - 0.5) * j * 2; oy += (Math.random() - 0.5) * j; }
          draw(ox * p.k, oy * p.k);
        }
      });

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

    function init() {
      document.querySelectorAll('[data-rgb-split-hover]').forEach(function (el) {
        if (el._rsh) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._rsh = true;
        el.classList.add('rsh');
        src.classList.add('rsh-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-rgb-split-hover style="height:400px">
  <img src="photo.jpg" alt="A mountain lake">
</figure>