← Loaders

Newton's Cradle

A row of shiny balls hangs from a bar. The end ball drops and knocks, the ball at the far end flies out, swings back and knocks again, just like the executive desk toy. Size it with font-size and colour it with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-newtons-cradle> where the loader should appear.

Options

Add any of these attributes to the same element.

data-ballsNumber of balls, 3 to 7. Default 5.
data-angleHow far the end balls swing out, in degrees. Default 36.
data-speedTime for one knock: a ball dropping in and the other flying out. Default 900 ms.
data-frame="false"No bar along the top.
data-colorsComma-separated colours, one per ball.

It leaves room at the sides for the swing, so nothing pokes out. It only swings while it’s on screen. With reduced motion the left ball hangs pulled back, ready to drop.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Newton's Cradle: a row of balls hangs from a bar; the end ball swings down
    // and knocks, the ball at the far end flies out, swings back and knocks
    // again, like the executive desk toy. Sized with font-size, coloured with color.
    // It only swings while it's on screen.
    // Usage: <div data-newtons-cradle aria-label="Loading"></div>
    // Optional: data-balls (3 to 7, default 5), data-speed (ms for one knock:
    // a ball dropping in and the other flying out, default 900), data-angle
    // (how far the end balls swing, in degrees, default 36), data-frame="false"
    // (no bar on top), data-colors (comma-separated, one per ball).
    var DEFAULTS = { balls: 5, speed: 900, angle: 36 };
    var BALL = 0.5, STRING = 1.5; // em

    var css =
      '.nwc{position:relative;display:inline-block;box-sizing:content-box}' +
      '.nwc-bar{position:absolute;top:0;height:0.1em;border-radius:0.05em;background:currentColor;opacity:.35}' +
      '.nwc-p{position:absolute;top:0.05em;width:' + BALL + 'em;height:' + (STRING + BALL) + 'em;transform-origin:50% 0}' +
      '.nwc-str{position:absolute;left:50%;top:0;width:0.05em;margin-left:-0.025em;height:' + STRING + 'em;background:currentColor;opacity:.4}' +
      '.nwc-ball{position:absolute;left:0;bottom:0;width:' + BALL + 'em;height:' + BALL + 'em;border-radius:50%;' +
        'background:radial-gradient(circle at 34% 30%,rgba(255,255,255,.75),rgba(255,255,255,0) 46%),currentColor;' +
        'box-shadow:inset -0.04em -0.06em 0.1em rgba(0,0,0,.25)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function build(el) {
      var d = el.dataset;
      var n = Math.max(3, Math.min(7, +d.balls || DEFAULTS.balls));
      var angle = Math.max(10, Math.min(60, +d.angle || DEFAULTS.angle));
      var colors = d.colors ? d.colors.split(',') : [];
      el.classList.add('nwc');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';

      // Leave room either side for the swing, so nothing pokes out.
      var reach = (STRING + BALL) * Math.sin(angle * Math.PI / 180);
      var side = Math.max(0.3, reach - BALL / 2 + 0.05);
      var row = n * BALL;
      el.style.width = row + 'em';
      el.style.height = STRING + BALL + 0.1 + 'em';
      el.style.padding = '0 ' + side.toFixed(3) + 'em';

      if (d.frame !== 'false') {
        var bar = document.createElement('span');
        bar.className = 'nwc-bar';
        bar.style.left = side - 0.2 + 'em';
        bar.style.width = row + 0.4 + 'em';
        el.appendChild(bar);
      }
      var swings = [];
      for (var i = 0; i < n; i++) {
        var p = document.createElement('span');
        p.className = 'nwc-p';
        p.style.left = side + i * BALL + 'em';
        p.innerHTML = '<span class="nwc-str"></span><span class="nwc-ball"></span>';
        if (colors.length) p.lastChild.style.color = colors[i % colors.length].trim();
        p.setAttribute('aria-hidden', 'true');
        el.appendChild(p);
        swings.push(p);
      }
      var first = swings[0], last = swings[n - 1];
      // Start with the left ball pulled back, ready to drop.
      anime.utils.set(first, { rotate: angle });
      if (reduced()) return null;

      var half = (+d.speed || DEFAULTS.speed) / 2;
      // Gravity: speeds up on the way down, slows on the way up.
      var tl = anime.createTimeline({ loop: true, autoplay: false });
      tl.add(first, { rotate: 0, duration: half, ease: 'in(2)' })
        .add(last, { rotate: -angle, duration: half, ease: 'out(2)' })
        .add(last, { rotate: 0, duration: half, ease: 'in(2)' })
        .add(first, { rotate: angle, duration: half, ease: 'out(2)' });
      // The balls in between give the faintest shiver at each knock.
      var mid = swings.slice(1, n - 1);
      [half, half * 3].forEach(function (t) {
        tl.add(mid, {
          x: [{ to: '0.012em', duration: 40 }, { to: '0em', duration: 120 }],
          delay: anime.stagger(12)
        }, t);
      });
      return [tl];
    }

    function init() {
      document.querySelectorAll('[data-newtons-cradle]').forEach(function (el) {
        if (el._nwc) return;
        el._nwc = true;
        var anims = build(el);
        if (!anims) return;
        new IntersectionObserver(function (entries) {
          anims.forEach(function (a) { entries[0].isIntersecting ? a.play() : a.pause(); });
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-newtons-cradle style="font-size: 32px; color: #6a0dad" aria-label="Loading"></div>