← Backgrounds

Halftone Wave

A field of halftone dots, like a printed picture seen up close, swells and shrinks in waves that roll across the section while rings ripple out from a slowly drifting centre.

Hot off the press

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-halftone-wave to a section with a plain background colour.

Options

Add any of these attributes to the same element.

data-colorDot colour. Default pink #db2777.
data-sizeGap between dots, in px. Default 16.
data-angleDirection the waves roll, in degrees. Default 30.
data-speedSpeed multiplier. Default 1.
data-fadeShrink the dots towards the middle, so text stays clear.

It’s drawn on a transparent canvas behind your content, sharp on high-density screens, and paused when off screen. With reduced motion it holds still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Halftone Wave: a field of halftone dots, like an old print magnified,
    // swells and shrinks in waves that roll across the section and ripple out
    // from a drifting centre. Drawn on a transparent canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-halftone-wave style="background:#fde68a">…</section>
    // Optional: data-color (dot colour, default #db2777), data-size (gap
    // between dots in px, default 16), data-speed (default 1), data-angle
    // (direction the waves roll, in degrees, default 30), data-fade (fade the
    // dots out towards the middle so text stays clear).
    var DEFAULTS = { color: '#db2777', size: 16, speed: 1, angle: 30 };

    var css =
      '[data-halftone-wave]{position:relative;isolation:isolate;overflow:hidden}' +
      '.htw-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var size = +d.size || DEFAULTS.size;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var angle = ((d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI) / 180;
      var fade = d.fade !== undefined && d.fade !== 'false';
      var dx = Math.cos(angle), dy = Math.sin(angle);
      var canvas = document.createElement('canvas');
      canvas.className = 'htw-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1, t = 2.5;

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

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = color;
        var max = size * 0.62;   // a full dot just touches its neighbours
        var rowH = size * 0.866;   // rows offset like a printer's screen
        // A centre that drifts slowly about, sending rings outwards.
        var cx = w * (0.5 + 0.3 * Math.sin(t * 0.13)), cy = h * (0.5 + 0.3 * Math.cos(t * 0.11));
        var k = (2 * Math.PI) / (size * 14);
        ctx.beginPath();
        for (var r = 0, y = size / 2; y < h + size; r++, y += rowH) {
          for (var x = (r & 1 ? size / 2 : 0); x < w + size; x += size) {
            var along = (x * dx + y * dy) * k;
            var dist = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) * k * 0.8;
            var v = 0.5 + 0.32 * Math.sin(along - t * 1.6) + 0.22 * Math.sin(dist - t * 1.1);
            if (fade) {
              var fx = (x - w / 2) / (w / 2), fy = (y - h / 2) / (h / 2);
              v *= Math.min(1, 0.15 + Math.sqrt(fx * fx + fy * fy) * 0.95);
            }
            var rad = max * v * v * 1.15;
            if (rad < 0.4) continue;
            ctx.moveTo(x + rad, y);
            ctx.arc(x, y, rad, 0, Math.PI * 2);
          }
        }
        ctx.fill();
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += ((last ? Math.min(50, now - last) : 16) / 1000) * speed;
          last = now;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-halftone-wave]').forEach(function (el) {
        if (el._htw) return;
        el._htw = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-halftone-wave data-fade style="background: #fde68a">
  <h1>Hot off the press</h1>
</section>