← Particles

Particle Repel

A field of dots scatters away from the pointer like filings from a magnet, swelling and lighting up as they’re pushed, then springs back into place once it moves on. Tap or click to send a shockwave through them.

Tap for a shockwave

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-particle-repel to a section, hero or card. Add data-autoplay so a pretend pointer moves through it whenever nobody is pointing, which keeps it alive on phones.

Options

Add any of these attributes to the same element.

data-autoplayA pretend pointer drifts through the dots when idle.
data-gapSpace between dots, in px. Default 18.
data-radiusHow far the push reaches, in px. Default 90.
data-strengthHow hard they’re pushed. Default 1.
data-colorColour of resting dots. Default #a78bfa.
data-activeColour of dots that have been pushed well away. Default #f0abfc.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the dots sit still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Particle Repel: a field of dots scatters away from the pointer like iron
    // filings from a magnet, swelling and lighting up as they're pushed, then
    // springs back into place when it moves on. A tap or click sends a
    // shockwave through them. With data-autoplay a pretend pointer drifts
    // through the field whenever nobody is pointing, so it's alive on phones.
    // Drawn on a transparent canvas behind your content, and paused when off
    // screen.
    // Usage: <section data-particle-repel>…</section>
    // Optional: data-autoplay (a pretend pointer wanders through when idle),
    // data-gap (space between dots in px, default 18), data-radius (how far
    // the push reaches in px, default 90), data-strength (default 1),
    // data-color (resting dot colour, default #a78bfa), data-active (colour of
    // pushed dots, default #f0abfc).
    var DEFAULTS = { gap: 18, radius: 90, strength: 1, color: '#a78bfa', active: '#f0abfc' };

    var css =
      '[data-particle-repel]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
      '.prp-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 = 'prp-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var gap = Math.max(6, +d.gap || DEFAULTS.gap);
      var radius = +d.radius || DEFAULTS.radius;
      var strength = d.strength !== undefined ? +d.strength : DEFAULTS.strength;
      var color = d.color || DEFAULTS.color;
      var active = d.active || DEFAULTS.active;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var w = 0, h = 0, dpr = 1, t = 0;
      var dots = [];
      var ptr = { x: -9999, y: -9999, on: false, real: -1 };
      var fake = { x: 0, y: 0 };
      var reach = 0;   // how present the pointer is, 0 to 1, so it eases in and out

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // A slightly jittered grid, centred in the box.
        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 r = 0; r < rows; r++) {
          for (var c = 0; c < cols; c++) {
            var hx = ox + c * gap + (Math.random() - 0.5) * gap * 0.35;
            var hy = oy + r * gap + (Math.random() - 0.5) * gap * 0.35;
            dots.push({ hx: hx, hy: hy, x: hx, y: hy, vx: 0, vy: 0, s: 0.8 + Math.random() * 0.5 });
          }
        }
      }

      function shockwave(x, y) {
        var big = radius * 2.4;
        for (var i = 0; i < dots.length; i++) {
          var p = dots[i];
          var dx = p.x - x, dy = p.y - y, dist = Math.sqrt(dx * dx + dy * dy) || 1;
          if (dist > big) continue;
          var k = (1 - dist / big);
          p.vx += (dx / dist) * k * k * 22 * strength;
          p.vy += (dy / dist) * k * k * 22 * strength;
        }
      }

      function advance(dt) {
        t += dt;
        var s = dt / 16;
        var real = t < ptr.real;
        if (!real && autoplay) {
          // A pretend pointer drifting in a lazy figure of eight.
          var k = t * 0.00055;
          fake.x = w * (0.5 + Math.sin(k) * 0.36);
          fake.y = h * (0.5 + Math.sin(k * 2) * 0.28);
        }
        var on = real ? ptr.on : autoplay;
        reach += ((on ? 1 : 0) - reach) * Math.min(1, dt * 0.006);
        var px = real ? ptr.x : fake.x, py = real ? ptr.y : fake.y;
        var spring = 1 - Math.pow(1 - 0.055, s), damp = Math.pow(0.84, s);
        for (var i = 0; i < dots.length; i++) {
          var p = dots[i];
          if (reach > 0.01) {
            var dx = p.x - px, dy = p.y - py;
            var d2 = dx * dx + dy * dy;
            if (d2 < radius * radius) {
              var dist = Math.sqrt(d2) || 1;
              var f = 1 - dist / radius;
              var push = f * f * 4.2 * strength * reach * s;
              p.vx += (dx / dist) * push;
              p.vy += (dy / dist) * push;
            }
          }
          p.vx += (p.hx - p.x) * spring;
          p.vy += (p.hy - p.y) * spring;
          p.vx *= damp;
          p.vy *= damp;
          p.x += p.vx * s;
          p.y += p.vy * s;
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // Three passes, from resting to fully pushed, each one path.
        var levels = [[0, 3, color, 0.55, 1.3], [3, 12, color, 0.9, 1.9], [12, 1e9, active, 1, 2.6]];
        for (var l = 0; l < levels.length; l++) {
          var lv = levels[l];
          ctx.beginPath();
          for (var i = 0; i < dots.length; i++) {
            var p = dots[i];
            var dx = p.x - p.hx, dy = p.y - p.hy;
            var off = Math.sqrt(dx * dx + dy * dy);
            if (off < lv[0] || off >= lv[1]) continue;
            var r = lv[4] * p.s + Math.min(1.6, off * 0.03);
            ctx.moveTo(p.x + r, p.y);
            ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
          }
          ctx.fillStyle = lv[2];
          ctx.globalAlpha = lv[3];
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

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

      function at(e) {
        var r = el.getBoundingClientRect();
        ptr.x = e.clientX - r.left;
        ptr.y = e.clientY - r.top;
      }
      el.addEventListener('pointermove', function (e) {
        at(e);
        ptr.on = true;
        ptr.real = t + (e.pointerType === 'mouse' ? 2000 : 1200);
      });
      el.addEventListener('pointerdown', function (e) {
        at(e);
        ptr.on = true;
        ptr.real = t + 1600;
        shockwave(ptr.x, ptr.y);
      });
      el.addEventListener('pointerleave', function () { ptr.on = false; ptr.real = t + 1500; });

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

    function init() {
      document.querySelectorAll('[data-particle-repel]').forEach(function (el) {
        if (el._prp) return;
        el._prp = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-particle-repel data-autoplay style="background: #120a24; color: #fff">
  <h1>Push back</h1>
</section>