← Filters & retro

Pixelate Hover

The image breaks into chunky pixels around the pointer, biggest blocks right underneath and smaller ones at the edge, then sharpens again behind it as it moves on, leaving a short trail of pixels.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-pixelate-hover 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-sizeThe biggest block, in pixels (rounded to a multiple of 4). Default 24.
data-radiusHow far the pixels reach around the pointer. Default 90 px.
data-fadeHow long the trail takes to sharpen again. Default 700 ms.
data-autoplayA pretend pointer drifts across while nobody is pointing. Handy on phones.

Touch works too: drag a finger across the picture.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pixelate Hover: the image breaks into chunky pixels around the pointer,
    // biggest blocks in the middle, and sharpens again behind it as it moves on,
    // leaving a short pixel trail. Dragging a finger works the same.
    // Usage: <figure data-pixelate-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-size (largest block, px, default 24), data-radius (reach
    // of the pointer, px, default 90), data-fade (ms to sharpen again, default
    // 700), data-autoplay (a pretend pointer drifts across while nobody is
    // pointing).
    var DEFAULTS = { size: 24, radius: 90, fade: 700 };

    var css =
      '.pxh{position:relative;overflow:hidden}' +
      '.pxh-src{opacity:0}' +
      '.pxh-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 setup(el, img, iw, ih) {
      var d = el.dataset;
      var size = Math.max(4, Math.round((+d.size || DEFAULTS.size) / 4) * 4);
      var radius = +d.radius || DEFAULTS.radius;
      var fade = +d.fade || DEFAULTS.fade;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'pxh-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var sharp = document.createElement('canvas');
      // Three levels of blockiness: full, half and quarter cell size. Each is a tiny
      // copy of the image, one pixel per block, stretched back up without smoothing.
      var blocks = [size, size / 2, size / 4];
      var levels = blocks.map(function () { return document.createElement('canvas'); });
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, heat = new Float32Array(0), dirty = true;

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        sharp.width = canvas.width;
        sharp.height = canvas.height;
        var sx = sharp.getContext('2d');
        sx.imageSmoothingQuality = 'high';
        sx.setTransform(dpr, 0, 0, dpr, 0, 0);
        cover(sx, img, iw, ih, w, h);
        levels.forEach(function (c, i) {
          var b = blocks[i];
          c.width = Math.ceil(w / b);
          c.height = Math.ceil(h / b);
          var x = c.getContext('2d');
          x.imageSmoothingQuality = 'high';
          // Shrink from the sharp copy so each block is roughly its area's average.
          x.drawImage(sharp, 0, 0, w * dpr, h * dpr, 0, 0, w / b, h / b);
        });
        cols = Math.ceil(w / size);
        rows = Math.ceil(h / size);
        heat = new Float32Array(cols * rows);
        dirty = true;
      }

      function draw() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.imageSmoothingEnabled = true;
        ctx.drawImage(sharp, 0, 0);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.imageSmoothingEnabled = false;
        var k = size;
        for (var r = 0; r < rows; r++) {
          for (var c = 0; c < cols; c++) {
            var v = heat[r * cols + c];
            if (v < 0.12) continue;
            var li = v > 0.62 ? 0 : v > 0.34 ? 1 : 2, b = blocks[li], n = k / b;
            var lv = levels[li];
            var sw = Math.min(n, lv.width - c * n), sh = Math.min(n, lv.height - r * n);
            if (sw <= 0 || sh <= 0) continue;
            ctx.drawImage(lv, c * n, r * n, sw, sh, c * k, r * k, sw * b, sh * b);
          }
        }
      }

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

      var real = null, lastReal = -1e9, t = 0, p = { x: -1e4, y: -1e4 };
      function move(e) {
        var r = el.getBoundingClientRect();
        var nx = e.clientX - r.left, ny = e.clientY - r.top;
        if (!real) { p.x = nx; p.y = ny; }
        real = { x: nx, y: ny };
        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, wasAuto = false;
      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;
          if (real) { target = real; wasAuto = false; }
          else if (autoplay && t - lastReal > 2200) {
            target = { x: w * (0.5 + 0.4 * Math.sin(t / 1500)), y: h * (0.5 + 0.32 * Math.sin(t / 1050 + 0.6)) };
            if (!wasAuto) { p.x = target.x; p.y = target.y; wasAuto = true; }
          }
          var active = false;
          // Cool every cell, then warm the ones near the pointer.
          var decay = Math.exp(-dt / (fade * 0.45));
          for (var i = 0; i < heat.length; i++) {
            if (heat[i] > 0.001) { heat[i] *= decay; active = true; } else heat[i] = 0;
          }
          if (target) {
            var e = 1 - Math.pow(0.7, dt / 16);
            p.x += (target.x - p.x) * e;
            p.y += (target.y - p.y) * e;
            var R = radius * Math.max(0.7, Math.min(1.3, w / 600));
            var c0 = Math.max(0, Math.floor((p.x - R) / size)), c1 = Math.min(cols - 1, Math.floor((p.x + R) / size));
            var r0 = Math.max(0, Math.floor((p.y - R) / size)), r1 = Math.min(rows - 1, Math.floor((p.y + R) / size));
            for (var r = r0; r <= r1; r++) {
              for (var c = c0; c <= c1; c++) {
                var dx = (c + 0.5) * size - p.x, dy = (r + 0.5) * size - p.y;
                var q = 1 - Math.sqrt(dx * dx + dy * dy) / R;
                if (q <= 0) continue;
                var j = r * cols + c, v = Math.min(1, q * 1.5);
                if (v > heat[j]) heat[j] = v;
              }
            }
            active = true;
          }
          if (active || dirty) { dirty = active; draw(); }
        }
      });

      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-pixelate-hover]').forEach(function (el) {
        if (el._pxh) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._pxh = true;
        el.classList.add('pxh');
        src.classList.add('pxh-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-pixelate-hover style="height:400px">
  <img src="photo.jpg" alt="Sand dunes at sunset">
</figure>