← Particles

Gravity Well

A glowing point pulls a cloud of particles into orbit. They whip round it in streaks, faster the closer they get, slowly spiral in and vanish into the core with a flare, while new ones drift in from the edges. The well follows your pointer or finger.

Move to steer

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-gravity-well to a section with a dark background. Add data-autoplay to have the well wander by itself whenever nobody is pointing.

Options

Add any of these attributes to the same element.

data-autoplayThe well drifts around by itself when idle. Without it, it rests in the middle.
data-densityParticles per 100 × 100 px. Default 8.
data-pullStrength of gravity. Higher means faster orbits. 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. On a phone the well follows your finger and wanders off again a moment after you let go. With reduced motion you see a still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Gravity Well: a glowing point pulls a cloud of particles into orbit.
    // They swirl around it in streaks, slowly spiral inwards and vanish into
    // the middle with a flash, while new ones drift in from the edges. The
    // well follows the pointer (or your finger), and with data-autoplay it
    // wanders by itself when nobody is pointing. Drawn on a transparent canvas
    // behind your content, and paused when off screen.
    // Usage: <section data-gravity-well>…</section>
    // Optional: data-autoplay (the well wanders by itself when idle),
    // data-density (particles per 100 × 100 px, default 8), data-pull
    // (strength of gravity, default 1), data-colors="#7c3aed,#c4b5fd,#fff"
    // (from slow to fast).
    var DEFAULTS = { density: 8, pull: 1, colors: ['#8b5cf6', '#a78bfa', '#c4b5fd', '#f5d0fe', '#ffffff'] };

    var css =
      '[data-gravity-well]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
      '.gvw-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}' +
      '.gvw-core{position:absolute;left:0;top:0;width:120px;height:120px;margin:-60px 0 0 -60px;border-radius:50%;z-index:-1;pointer-events:none;' +
      'background:radial-gradient(circle,#fff 0,rgba(255,255,255,.9) 6%,rgba(216,180,254,.6) 16%,rgba(139,92,246,.22) 34%,rgba(139,92,246,0) 60%)}';
    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 = 'gvw-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var core = document.createElement('span');
      core.className = 'gvw-core';
      core.setAttribute('aria-hidden', 'true');
      canvas.after(core);
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var G = 360 * (d.pull !== undefined ? +d.pull : DEFAULTS.pull);
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0;
      var parts = [];
      var well = { x: 0, y: 0 }, goal = { x: 0, y: 0 };
      var pointerUntil = -1, glow = 0;
      var SOFT = 22 * 22, HORIZON = 9, MAX_V = 9;

      // Drop a particle somewhere around the well, already moving roughly in orbit.
      function spawn(p, anywhere) {
        var reach = Math.min(Math.max(w, h) * 0.6, 460);
        var dist = anywhere ? R(30, reach) : R(reach * 0.5, reach);
        var a = Math.random() * Math.PI * 2;
        p.x = well.x + Math.cos(a) * dist;
        p.y = well.y + Math.sin(a) * dist * 0.8;
        var dx = p.x - well.x, dy = p.y - well.y, r = Math.sqrt(dx * dx + dy * dy) || 1;
        var v = Math.sqrt(G / Math.sqrt(r * r + SOFT)) * R(0.75, 1.05, 2);
        var dir = Math.random() < 0.88 ? 1 : -1;   // most swirl the same way
        p.vx = (-dy / r) * v * dir;
        p.vy = (dx / r) * v * dir;
        p.age = 0;
        p.px = p.x; p.py = p.y;
        return p;
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var ow = w, oh = h;
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        if (!w || !h) return;
        if (!ow || !oh) { well.x = goal.x = w / 2; well.y = goal.y = h / 2; }
        else {
          parts.forEach(function (p) { p.x *= w / ow; p.y *= h / oh; });
          well.x *= w / ow; well.y *= h / oh; goal.x *= w / ow; goal.y *= h / oh;
        }
        var n = Math.round(Math.min(1400, Math.max(120, (w * h) / 10000 * density)));
        while (parts.length < n) parts.push(spawn({}, true));
        parts.length = n;
      }

      function wander() {
        var k = t * 0.0003;
        goal.x = w * (0.5 + Math.sin(k) * 0.3 + Math.sin(k * 2.3 + 1) * 0.06);
        goal.y = h * (0.5 + Math.sin(k * 1.7 + 0.6) * 0.24);
      }

      function advance(dt, mark) {
        t += dt;
        if (t > pointerUntil) {
          if (autoplay) wander();
          else { goal.x = w / 2; goal.y = h / 2; }
        }
        var ease = Math.min(1, dt * 0.0035);
        well.x += (goal.x - well.x) * ease;
        well.y += (goal.y - well.y) * ease;
        glow *= Math.pow(0.996, dt);
        var s = dt / 16;
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          p.age += dt;
          var dx = well.x - p.x, dy = well.y - p.y;
          var r2 = dx * dx + dy * dy;
          var r = Math.sqrt(r2) || 1;
          if (r < HORIZON) {
            glow = Math.min(1, glow + 0.06);
            spawn(p, false);
            continue;
          }
          var f = G / Math.pow(r2 + SOFT, 1.5);
          if (mark !== false) { p.px = p.x; p.py = p.y; }
          p.vx += dx * f * s;
          p.vy += dy * f * s;
          // A little drag, so orbits slowly decay into the well.
          var drag = Math.pow(0.9975, s);
          p.vx *= drag; p.vy *= drag;
          var v = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
          if (v > MAX_V) { p.vx *= MAX_V / v; p.vy *= MAX_V / v; }
          p.x += p.vx * s;
          p.y += p.vy * s;
          if (p.x < -w * 0.6 || p.x > w * 1.6 || p.y < -h * 0.6 || p.y > h * 1.6) spawn(p, false);
        }
      }

      // Each frame fades what's there a little and draws on top, leaving trails.
      function draw(fresh) {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        if (fresh) ctx.clearRect(0, 0, w, h);
        else {
          ctx.globalCompositeOperation = 'destination-out';
          ctx.fillStyle = 'rgba(0,0,0,.2)';
          ctx.fillRect(0, 0, w, h);
        }
        // Streaks from where each particle was to where it is, batched by speed
        // so each colour is one path.
        ctx.globalCompositeOperation = 'lighter';
        ctx.lineCap = 'butt';
        var buckets = colors.length;
        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 / 4.2) * buckets));
            if (k !== b || p.age < 30) continue;
            ctx.moveTo(fresh ? p.x - p.vx * 3 : p.px, fresh ? p.y - p.vy * 3 : p.py);
            ctx.lineTo(p.x + 0.01, p.y);
          }
          ctx.strokeStyle = colors[b];
          ctx.globalAlpha = 0.75 + (b / buckets) * 0.25;
          ctx.lineWidth = 1.4 + b * 0.15;
          ctx.stroke();
        }
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
        // The well: a bright core and a halo that flares as it swallows particles.
        core.style.transform = 'translate(' + well.x.toFixed(1) + 'px,' + well.y.toFixed(1) + 'px) scale(' + (0.8 + glow * 0.5).toFixed(3) + ')';
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(true); }).observe(el);
      for (var warm = 0; warm < 90; warm++) advance(16);

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

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

      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;
          // Two half steps keep the close orbits steady.
          advance(dt / 2, true);
          advance(dt / 2, false);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-gravity-well]').forEach(function (el) {
        if (el._gvw) return;
        el._gvw = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-gravity-well data-autoplay style="background: #0b0616; color: #fff">
  <h1>Pulled in</h1>
</section>