← Filters & retro

Halftone Image

Redraw an image as a printed halftone: a screen of dots on a 45° grid, big where the picture is dark and small where it’s light. The dots grow in when it scrolls into view, then swell under the pointer like ink under a magnifier.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-halftone-image and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.

Options

Add any of these attributes to the same element.

data-spacingPixels between dots. Default 9.
data-angleAngle of the dot grid in degrees. Default 45.
data-inkDot colour. Default #1d1b2f.
data-paperBackground colour. Default a warm off-white, or near-black with data-color.
data-colorDots take the image’s own colours, bright areas getting big dots, on dark paper.
data-swellHow much the dots grow under the pointer. Default 1; 0 turns it off.
data-autoplayA pretend pointer drifts across while nobody is pointing. Handy on phones.

Touch works too: drag a finger across 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 () {
    // Halftone Image: redraws an image as a printed halftone, a screen of dots
    // on a 45-degree grid, big where the picture is dark and small where it is
    // light. The dots grow in when it scrolls into view, and swell under the
    // pointer (or a finger) like ink under a magnifier.
    // Usage: <figure data-halftone-image><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box and
    // is hidden behind the dots, but stays for screen readers.
    // Optional: data-spacing (px between dots, default 9), data-angle (grid angle
    // in degrees, default 45), data-ink (dot colour, default #1d1b2f),
    // data-paper (background, default #f6f0e4), data-color (dots take the
    // image's own colours, on a dark paper unless you set one),
    // data-swell (how much dots grow under the pointer, default 1),
    // data-autoplay (a pretend pointer drifts across while nobody is pointing).
    var DEFAULTS = { spacing: 9, angle: 45, ink: '#1d1b2f', paper: '#f6f0e4', darkPaper: '#111018', swell: 1, radius: 110 };

    var css =
      '.hft{position:relative;overflow:hidden}' +
      '.hft-src{opacity:0}' +
      '.hft-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 = 1000 / 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 spacing = +d.spacing || DEFAULTS.spacing;
      var angle = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
      var color = d.color !== undefined && d.color !== 'false';
      var ink = d.ink || DEFAULTS.ink;
      var paper = d.paper || (color ? DEFAULTS.darkPaper : DEFAULTS.paper);
      var swellBy = d.swell !== undefined ? +d.swell : DEFAULTS.swell;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'hft-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var sample = document.createElement('canvas');
      var sctx = sample.getContext('2d', { willReadFrequently: true });
      var w = 0, h = 0, dpr = 1, dots = [], grow = 0;
      var pointer = { x: 0, y: 0, k: 0 }, real = null, lastReal = -1e9, t = 0;

      // Sample the image once per size: every dot keeps its spot, tone and colour.
      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        sample.width = w;
        sample.height = h;
        cover(sctx, img, iw, ih, w, h);
        var px;
        try { px = sctx.getImageData(0, 0, w, h).data; }
        catch (err) { canvas.remove(); el.classList.remove('hft'); el.querySelector('.hft-src').classList.remove('hft-src'); return; }
        dots = [];
        var cos = Math.cos(angle), sin = Math.sin(angle);
        var reach = Math.hypot(w, h) / 2 + spacing, cx = w / 2, cy = h / 2;
        for (var v = -reach; v <= reach; v += spacing) {
          for (var u = -reach; u <= reach; u += spacing) {
            var x = cx + u * cos - v * sin, y = cy + u * sin + v * cos;
            if (x < -spacing || y < -spacing || x > w + spacing || y > h + spacing) continue;
            var sx = Math.min(w - 1, Math.max(0, x | 0)), sy = Math.min(h - 1, Math.max(0, y | 0));
            var i = (sy * w + sx) * 4;
            var lum = (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]) / 255;
            // Ink: dark areas get big dots. Colour: bright areas do, on dark paper.
            var tone = color ? 0.25 + 0.75 * lum : Math.pow(1 - lum, 1.2);
            dots.push({ x: x, y: y, r: Math.sqrt(tone) * spacing * 0.62, c: color ? 'rgb(' + px[i] + ',' + px[i + 1] + ',' + px[i + 2] + ')' : null });
          }
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = paper;
        ctx.fillRect(0, 0, w, h);
        var R = DEFAULTS.radius * Math.max(0.6, Math.min(1.4, w / 600)), R2 = R * R;
        var max = spacing * 0.78;
        ctx.fillStyle = ink;
        if (!color) ctx.beginPath();
        for (var i = 0; i < dots.length; i++) {
          var dt = dots[i], r = dt.r * grow;
          if (pointer.k > 0.01) {
            var dx = dt.x - pointer.x, dy = dt.y - pointer.y, q = (dx * dx + dy * dy) / R2;
            if (q < 4) r = Math.min(max, r * (1 + 0.9 * Math.exp(-q * 2.2) * pointer.k * swellBy) + 0.8 * Math.exp(-q * 3) * pointer.k * swellBy);
          }
          if (r < 0.3) continue;
          if (color) {
            ctx.fillStyle = dt.c;
            ctx.beginPath();
            ctx.arc(dt.x, dt.y, r, 0, Math.PI * 2);
            ctx.fill();
          } else {
            ctx.moveTo(dt.x + r, dt.y);
            ctx.arc(dt.x, dt.y, r, 0, Math.PI * 2);
          }
        }
        if (!color) ctx.fill();
      }

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

      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; });
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') real = null; });

      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;
          // Follow the real pointer, or wander when there's none and autoplay is on.
          var target = null;
          if (real) target = real;
          else if (autoplay && t - lastReal > 2500) {
            target = { x: w * (0.5 + 0.36 * Math.sin(t / 1700)), y: h * (0.5 + 0.3 * Math.sin(t / 1100 + 1)) };
          }
          var e = 1 - Math.pow(0.88, dt / 16);
          if (target) {
            if (pointer.k < 0.02) { pointer.x = target.x; pointer.y = target.y; }
            pointer.x += (target.x - pointer.x) * e;
            pointer.y += (target.y - pointer.y) * e;
          }
          pointer.k += ((target ? 1 : 0) - pointer.k) * e * 0.6;
          draw();
        }
      });

      // Dots grow in from nothing the first time it is seen; then it keeps drawing while visible.
      var shown = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!shown) {
            shown = true;
            var g = { v: 0 };
            anime.animate(g, { v: 1, duration: 1200, ease: 'out(3)', onUpdate: function () { grow = g.v; } });
          }
          last = 0;
          timer.play();
        } else timer.pause();
      }, { threshold: 0.1 }).observe(el);
    }

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