← Backgrounds

Dot Grid Ripple

A quiet grid of dots sits behind a section. Move the pointer and the dots near it light up; click and a ring of light ripples out across the grid, nudging the dots aside as it passes. Left alone, it can ripple on its own.

Click anywhere

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-dot-grid-ripple to a section, hero or card.

Options

Add any of these attributes to the same element.

data-loopRipple on its own every couple of seconds, while the pointer is away.
data-intervalTime between those ripples. Default 1800 ms.
data-colorDot colour. Default the text colour, faded.
data-accentRipple colour. Default #a855f7.
data-gapSpace between dots in px. Default 22.
data-sizeDot radius in px. Default 1.4.

It’s drawn on a canvas behind your content and paused when off screen. Clicks still reach your links and buttons. With reduced motion the dots stay still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dot Grid Ripple: a quiet grid of dots behind a section. Move the pointer
    // and the dots near it swell; click or tap and a ring of light ripples out
    // across the grid, nudging dots outward as it passes. With data-loop,
    // ripples also start on their own. Drawn on a canvas behind your content,
    // and paused when off screen.
    // Usage: <section data-dot-grid-ripple data-loop>…</section>
    // Optional: data-color (dots, default the text colour), data-accent
    // (ripple colour, default #a855f7), data-gap (px between dots, default
    // 22), data-size (dot radius in px, default 1.4), data-loop (ripple on its
    // own), data-interval (ms between those ripples, default 1800).
    var DEFAULTS = { accent: '#a855f7', gap: 22, size: 1.4, interval: 1800 };

    var css =
      '[data-dot-grid-ripple]{position:relative;isolation:isolate;overflow:hidden}' +
      '.dg-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 canvas = document.createElement('canvas');
      canvas.className = 'dg-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var gap = +d.gap || DEFAULTS.gap;
      var size = +d.size || DEFAULTS.size;
      var accent = d.accent || DEFAULTS.accent;
      var color = d.color || getComputedStyle(el).color;
      var w = 0, h = 0, dpr = 1, dots = [];
      var ripples = [];
      var pointer = { x: -1e4, y: -1e4, on: 0 };

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Centre the grid so the margins match on every side.
        dots = [];
        var cols = Math.floor(w / gap), rows = Math.floor(h / gap);
        var ox = (w - (cols - 1) * gap) / 2, oy = (h - (rows - 1) * gap) / 2;
        for (var j = 0; j < rows; j++) for (var i = 0; i < cols; i++) dots.push(ox + i * gap, oy + j * gap);
        draw();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var lens = gap * 4.5;
        // Dots at rest go in one batch; lit or moved dots are drawn after.
        var lit = [];
        ctx.fillStyle = color;
        ctx.globalAlpha = 0.32;
        ctx.beginPath();
        for (var k = 0; k < dots.length; k += 2) {
          var x = dots[k], y = dots[k + 1], glow = 0, dx, dy, dist;
          for (var r = 0; r < ripples.length; r++) {
            var rp = ripples[r];
            dx = x - rp.x; dy = y - rp.y;
            dist = Math.sqrt(dx * dx + dy * dy) || 1;
            // A ring about two dots wide, fading as it grows.
            var t = (dist - rp.radius) / (gap * 2.2);
            if (t > -1 && t < 1) {
              var f = (1 - t * t) * rp.strength;
              glow = Math.max(glow, f);
              x += (dx / dist) * f * gap * 0.45;
              y += (dy / dist) * f * gap * 0.45;
            }
          }
          if (pointer.on) {
            dx = x - pointer.x; dy = y - pointer.y;
            dist = Math.sqrt(dx * dx + dy * dy);
            if (dist < lens) glow = Math.max(glow, (1 - dist / lens) * 0.75 * pointer.on);
          }
          if (glow > 0.02) { lit.push(x, y, glow); continue; }
          ctx.moveTo(x + size, y);
          ctx.arc(x, y, size, 0, Math.PI * 2);
        }
        ctx.fill();
        ctx.fillStyle = accent;
        for (var m = 0; m < lit.length; m += 3) {
          ctx.globalAlpha = 0.3 + lit[m + 2] * 0.7;
          ctx.beginPath();
          ctx.arc(lit[m], lit[m + 1], size * (1 + lit[m + 2] * 2), 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

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

      var reach = function (x, y) { return Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) + gap * 2; };
      // Each ripple is an anime.js tween of a radius and a strength.
      function ripple(x, y, strength) {
        var rp = { x: x, y: y, radius: 0, strength: strength };
        ripples.push(rp);
        var far = reach(x, y);
        anime.animate(rp, {
          radius: far,
          // It stays bright for most of the way, then fades.
          strength: { to: 0, ease: 'in(2)' },
          duration: far * 3.2,
          ease: 'out(1.3)',
          onComplete: function () { ripples.splice(ripples.indexOf(rp), 1); }
        });
      }

      // Redraw while something is moving; a still grid is drawn once.
      var still = false;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          if (!ripples.length && !pointer.on) { if (still) return; still = true; } else still = false;
          draw();
        }
      });
      var hovering = false, last = 0;
      var local = function (e) { var r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
      el.addEventListener('pointermove', function (e) {
        var p = local(e);
        pointer.x = p[0]; pointer.y = p[1];
        if (!hovering && e.pointerType !== 'touch') {
          hovering = true;
          anime.animate(pointer, { on: 1, duration: 300, ease: 'out(2)' });
        }
        // A faint ripple trails the pointer as it moves.
        var now = performance.now();
        if (now - last > 420) { last = now; ripple(p[0], p[1], 0.35); }
      });
      el.addEventListener('pointerleave', function () {
        hovering = false;
        anime.animate(pointer, { on: 0, duration: 400, ease: 'out(2)' });
      });
      el.addEventListener('pointerdown', function (e) {
        var p = local(e);
        ripple(p[0], p[1], 1);
      });

      var loop = d.loop !== undefined && d.loop !== 'false';
      var auto = loop ? anime.createTimer({
        duration: +d.interval || DEFAULTS.interval,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (hovering) return;
          var R = anime.utils.random;
          ripple(R(0.15, 0.85, 3) * w, R(0.2, 0.8, 3) * h, 1);
        }
      }) : null;
      new IntersectionObserver(function (entries) {
        var on = entries[0].isIntersecting;
        [timer, auto].forEach(function (t) { if (t) { if (on) t.play(); else t.pause(); } });
        if (on && auto && !ripples.length) ripple(w / 2, h / 2, 1);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-dot-grid-ripple]').forEach(function (el) {
        if (el._dg) return;
        el._dg = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-dot-grid-ripple data-loop style="background: #0f0a1e; color: #fff">
  <h1>Hello</h1>
</section>