← Particles

Pointer Swarm

A swarm of glowing particles streams after the pointer like a shoal of fish. Move quickly and it stretches into a ribbon; stop and it overshoots, then curls round the pointer in loose eddies. On a phone it follows your finger.

Lead the swarm

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pointer-swarm to a section with a dark background. Add data-autoplay so the swarm chases a wandering point whenever nobody is pointing, which keeps it alive on phones.

Options

Add any of these attributes to the same element.

data-autoplayThe swarm chases a wandering point by itself when idle. Without it, it gathers in the middle.
data-densityParticles per 100 × 100 px. Default 6.
data-speedHow fast they fly. Default 1.
data-colorsComma-separated colours, from the slowest particles to the fastest.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion you see the swarm gathered, still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pointer Swarm: a swarm of glowing particles streams after the pointer
    // (or your finger) like a shoal of fish. Move quickly and it stretches out
    // into a ribbon; stop and it overshoots, then curls round the pointer in
    // loose eddies. Drawn on a canvas behind your content, and paused when off
    // screen.
    // Usage: <section data-pointer-swarm>…</section>
    // Optional: data-autoplay (the swarm chases a wandering point by itself
    // when nobody is pointing), data-density (particles per 100 × 100 px,
    // default 6), data-speed (how fast they fly, default 1), data-colors
    // (comma-separated, from slow to fast).
    var DEFAULTS = { density: 6, speed: 1, colors: ['#22d3ee', '#38bdf8', '#818cf8', '#c084fc', '#f0abfc'] };

    var css =
      '[data-pointer-swarm]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent;touch-action:pan-y}' +
      '.swm-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);

    // Smooth value noise, used to stir small eddies into the swarm.
    var perm = [];
    for (var i = 0; i < 512; i++) perm[i] = Math.random();
    function hash(x, y) { return perm[((x * 73856093) ^ (y * 19349663)) & 511]; }
    function noise(x, y) {
      var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
      var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
      var a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
      return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'swm-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var w = 0, h = 0, dpr = 1, t = 0, scale = 1;
      var parts = [], target = { x: 0, y: 0 }, pointerUntil = -1;

      function spawn(p) {
        p.x = Math.random() * w;
        p.y = Math.random() * h;
        p.vx = 0; p.vy = 0;
        p.px = p.x; p.py = p.y;
        // Each one likes to circle at its own distance, mostly the same way round.
        p.r = (14 + Math.pow(Math.random(), 1.5) * 90) * scale;
        p.spin = Math.random() < 0.85 ? 1 : -1;
        p.agility = 0.7 + Math.random() * 0.6;
        return p;
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var ow = w, oh = h;
        w = el.clientWidth;
        h = el.clientHeight;
        scale = Math.max(0.55, Math.min(1.2, Math.min(w, h * 1.5) / 560));
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        if (!w || !h) return;
        if (!ow) { target.x = w / 2; target.y = h / 2; }
        else { parts.forEach(function (p) { p.x *= w / ow; p.y *= h / oh; p.px = p.x; p.py = p.y; }); }
        var n = Math.round(Math.min(900, Math.max(160, (w * h) / 10000 * density)));
        while (parts.length < n) parts.push(spawn({}));
        parts.length = n;
      }

      function wander() {
        var k = t * 0.00042;
        target.x = w * (0.5 + 0.28 * Math.sin(k) + 0.06 * Math.sin(k * 3.1 + 2));
        target.y = h * (0.5 + 0.24 * Math.sin(k * 1.6 + 1) + 0.05 * Math.cos(k * 4.3));
      }

      // Velocities in px per ms.
      function step(dt) {
        t += dt;
        if (t > pointerUntil) {
          if (autoplay) wander();
          else { target.x += (w / 2 - target.x) * 0.02; target.y += (h / 2 - target.y) * 0.02; }
        }
        var maxV = 0.55 * speed * scale, orbit = 0.16 * speed * scale;
        var ease = 1 - Math.pow(0.955, dt / 16);
        var ns = 0.012 / scale, tt = t * 0.0002;
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          p.px = p.x; p.py = p.y;
          var dx = target.x - p.x, dy = target.y - p.y;
          var dist = Math.sqrt(dx * dx + dy * dy) || 1;
          var nx = dx / dist, ny = dy / dist;
          // In towards its favourite distance, and round the pointer.
          var pull = Math.max(-0.5, Math.min(1, (dist - p.r) / (120 * scale))) * maxV;
          var vx = nx * pull - ny * orbit * p.spin;
          var vy = ny * pull + nx * orbit * p.spin;
          // Little eddies from a drifting noise field.
          var qx = p.x * ns, qy = p.y * ns, e = 0.35;
          vx += (noise(qx + tt, qy + e) - noise(qx + tt, qy - e)) * 0.35 * speed * scale;
          vy -= (noise(qx + e, qy - tt) - noise(qx - e, qy - tt)) * 0.35 * speed * scale;
          var k = ease * p.agility;
          p.vx += (vx - p.vx) * k;
          p.vy += (vy - p.vy) * k;
          p.x += p.vx * dt;
          p.y += p.vy * dt;
        }
      }

      function draw(fresh) {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        if (fresh) ctx.clearRect(0, 0, w, h);
        else {
          // Fade what's there a little each frame, leaving short trails.
          ctx.globalCompositeOperation = 'destination-out';
          ctx.fillStyle = 'rgba(0,0,0,.24)';
          ctx.fillRect(0, 0, w, h);
        }
        ctx.globalCompositeOperation = 'lighter';
        ctx.lineCap = 'round';
        var buckets = colors.length, top = 0.5 * speed * scale;
        for (var b = 0; b < buckets; b++) {
          ctx.beginPath();
          for (var i = 0; i < parts.length; i++) {
            var p = parts[i];
            var v = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
            var k = Math.min(buckets - 1, Math.floor((v / top) * buckets));
            if (k !== b) continue;
            ctx.moveTo(fresh ? p.x - p.vx * 30 : p.px, fresh ? p.y - p.vy * 30 : p.py);
            ctx.lineTo(p.x + 0.01, p.y);
          }
          ctx.strokeStyle = colors[b];
          ctx.globalAlpha = 0.8;
          ctx.lineWidth = 1.6 + b * 0.2;
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(true); }).observe(el);
      // Let it gather into a swarm before it's first seen.
      for (var warm = 0; warm < 150; warm++) step(16);

      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        draw(true);
        return;
      }

      function point(e, hold) {
        var r = el.getBoundingClientRect();
        target.x = e.clientX - r.left;
        target.y = e.clientY - r.top;
        pointerUntil = t + hold;
      }
      el.addEventListener('pointermove', function (e) { point(e, e.pointerType === 'mouse' ? 1500 : 2500); });
      el.addEventListener('pointerdown', function (e) { point(e, 2500); });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') pointerUntil = t + 400; });

      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;
          step(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-pointer-swarm]').forEach(function (el) {
        if (el._swm) return;
        el._swm = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-pointer-swarm data-autoplay style="background: #050b18; color: #fff">
  <h1>Follow me</h1>
</section>