← Particles

Ball Pit

A heap of glossy, colourful balls fills the bottom of a section. Sweep the pointer or a finger through them and they’re shoved aside, tumble over each other and settle back into a pile.

Dive in

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ball-pit to a section. Add data-autoplay so a pretend hand sweeps through the pile now and then whenever nobody is pointing, which keeps it alive on phones.

Options

Add any of these attributes to the same element.

data-autoplayA pretend hand sweeps through the pile every few seconds when idle.
data-fillHow much of the height the pile fills. Default 0.45.
data-sizeBall radius in px. Default 18, smaller on narrow screens.
data-colorsComma-separated colours.

It’s simple circle physics on a canvas behind your content, paused when off screen. The pile scales with the size of the section, up to 260 balls. With reduced motion the balls sit still in their heap.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ball Pit: a pile of glossy, colourful balls fills the bottom of a section.
    // Sweep the pointer (or a finger) through them and they're shoved aside,
    // tumble over each other and settle back into a heap under gravity.
    // Simple circle physics on a canvas behind your content, paused when off
    // screen.
    // Usage: <section data-ball-pit>…</section>
    // Optional: data-autoplay (a pretend hand sweeps through the pile every
    // few seconds when nobody is pointing), data-fill (how much of the height
    // the pile fills, default 0.45), data-size (ball radius in px, default 18;
    // smaller on narrow screens), data-colors (comma-separated).
    var DEFAULTS = { fill: 0.45, size: 18, colors: ['#f43f5e', '#f59e0b', '#facc15', '#22c55e', '#0ea5e9', '#8b5cf6', '#ec4899'] };

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

    // One pre-drawn glossy sprite per colour, reused for every ball.
    function sprite(color, r, dpr) {
      var c = document.createElement('canvas');
      var s = Math.ceil(r * 2 * dpr) + 2;
      c.width = c.height = s;
      var x = c.getContext('2d');
      x.scale(dpr, dpr);
      var m = s / dpr / 2;
      x.fillStyle = color;
      x.beginPath(); x.arc(m, m, r, 0, 6.283); x.fill();
      var g = x.createRadialGradient(m - r * 0.35, m - r * 0.4, r * 0.05, m, m, r);
      g.addColorStop(0, 'rgba(255,255,255,.75)');
      g.addColorStop(0.35, 'rgba(255,255,255,.08)');
      g.addColorStop(0.8, 'rgba(0,0,0,0)');
      g.addColorStop(1, 'rgba(0,0,0,.28)');
      x.fillStyle = g;
      x.beginPath(); x.arc(m, m, r, 0, 6.283); x.fill();
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'bpt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var fill = +d.fill || DEFAULTS.fill;
      var baseSize = +d.size || DEFAULTS.size;
      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, R = baseSize;
      var balls = [], sprites = {};
      var hand = { x: -999, y: -999, r: 40, on: false };
      var pointerUntil = -1, sweep = null, acc = 0;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var ow = w;
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        if (!w || !h) return;
        R = Math.max(8, Math.min(baseSize, w / 20));
        hand.r = R * 2.4;
        sprites = {};
        colors.forEach(function (c) { sprites[c] = sprite(c, R, dpr); });
        // Enough balls to fill the chosen share of the height, loosely packed.
        var n = Math.round(Math.min(260, (w * h * fill) / (R * R * 4.2)));
        if (ow) balls.forEach(function (b) { b.x *= w / ow; b.px *= w / ow; });
        while (balls.length < n) {
          var bx = R + Math.random() * (w - 2 * R), by = h - Math.random() * h * 1.6 - R;
          balls.push({ x: bx, y: by, px: bx, py: by, c: colors[balls.length % colors.length] });
        }
        balls.length = n;
      }
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      // Verlet steps: each ball remembers where it was, so pushing it moves it
      // and gives it the matching speed, which keeps the heap stable.
      function physics() {
        var i, j, a, b, g = 0.16;
        for (i = 0; i < balls.length; i++) {
          a = balls[i];
          var vx = (a.x - a.px) * 0.992, vy = (a.y - a.py) * 0.992;
          a.px = a.x; a.py = a.y;
          a.x += vx; a.y += vy + g;
        }
        for (var it = 0; it < 3; it++) {
          for (i = 0; i < balls.length; i++) {
            a = balls[i];
            for (j = i + 1; j < balls.length; j++) {
              b = balls[j];
              var dx = b.x - a.x, dy = b.y - a.y;
              if (dx > 2 * R || dx < -2 * R || dy > 2 * R || dy < -2 * R) continue;
              var d2 = dx * dx + dy * dy;
              if (d2 >= 4 * R * R || d2 === 0) continue;
              var dist = Math.sqrt(d2), push = (2 * R - dist) / 2, nx = dx / dist, ny = dy / dist;
              a.x -= nx * push; a.y -= ny * push;
              b.x += nx * push; b.y += ny * push;
            }
            if (hand.on) {
              // The hand: a big invisible ball moving with the pointer.
              var hx = a.x - hand.x, hy = a.y - hand.y, hd2 = hx * hx + hy * hy, min = R + hand.r;
              if (hd2 < min * min && hd2 > 0) {
                var hd = Math.sqrt(hd2), hn = Math.min(R * 0.6, min - hd);
                a.x += (hx / hd) * hn; a.y += (hy / hd) * hn;
              }
            }
            // Walls and floor (with a bit of friction on the floor).
            if (a.x < R) { a.x = R; a.px = a.x + (a.x - a.px) * 0.4; }
            if (a.x > w - R) { a.x = w - R; a.px = a.x + (a.x - a.px) * 0.4; }
            if (a.y > h - R) { a.y = h - R; a.px = a.x - (a.x - a.px) * 0.9; }
          }
        }
      }

      function step(dt) {
        t += dt;
        if (t > pointerUntil) {
          if (autoplay) {
            // A pretend hand dips in from one side, sweeps across and lifts out.
            if (!sweep || t > sweep.end) {
              var dir = Math.random() < 0.5 ? 1 : -1;
              sweep = { start: t + 900, end: t + 900 + 1700, dir: dir, depth: 0.55 + Math.random() * 0.25 };
            }
            var k = (t - sweep.start) / (sweep.end - sweep.start);
            if (k >= 0 && k <= 1) {
              var x = sweep.dir > 0 ? -hand.r + k * (w + 2 * hand.r) : w + hand.r - k * (w + 2 * hand.r);
              var pile = h * fill;
              hand.x = x;
              hand.y = h - pile * sweep.depth * Math.sin(k * Math.PI) - R;
              hand.on = true;
            } else hand.on = false;
          } else hand.on = false;
        }
        // Fixed 8 ms steps, however long the frame was.
        acc += dt;
        while (acc >= 8) { acc -= 8; physics(); }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var size = R * 2 + 2 / dpr;
        for (var i = 0; i < balls.length; i++) {
          var b = balls[i];
          if (b.y < -R) continue;
          ctx.drawImage(sprites[b.c], b.x - R, b.y - R, size, size);
        }
      }

      // Settle the pile before it's ever seen.
      for (var warm = 0; warm < 120; warm++) step(16);
      draw();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      function point(e, hold) {
        var r = el.getBoundingClientRect();
        var x = e.clientX - r.left, y = e.clientY - r.top;
        hand.x = x; hand.y = y; hand.on = true;
        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 () { hand.on = false; pointerUntil = t + 1500; });
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') { hand.on = false; } });

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(32, 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-ball-pit]').forEach(function (el) {
        if (el._bpt) return;
        el._bpt = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-ball-pit data-autoplay>
  <h1>Dive in</h1>
</section>