← Loaders

Spoke Spinner

The classic spinner: a ring of spokes that light up one after another round the circle, each fading as the next one brightens. Swap the bars for dots, or change the number of spokes. 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-spoke-spinner> where the loader should appear.

Options

Add any of these attributes to the same element.

data-spokesNumber of spokes, 6 to 16. Default 12.
data-speedTime for one lap. Default 1000 ms.
data-thicknessSpoke width as a share of the size. Default 0.09.
data-lengthSpoke length as a share of the radius. Default 0.46.
data-shape="dot"Round dots instead of bars.
data-colorsComma-separated colours, cycling round the spokes.

It only spins while it’s on screen. With reduced motion it shows a still frame, a bright spoke with a fading tail.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spoke Spinner: the classic spinner, a ring of spokes that light up one
    // after another round the circle, each fading as the next one brightens.
    // Sized with font-size, coloured with color.
    // It only spins while it's on screen.
    // Usage: <div data-spoke-spinner aria-label="Loading"></div>
    // Optional: data-spokes (6 to 16, default 12), data-speed (ms for one lap,
    // default 1000), data-thickness (spoke width as a share of the size,
    // default 0.09), data-length (spoke length as a share of the radius,
    // default 0.46), data-shape="dot" (round dots instead of bars),
    // data-colors (comma-separated, cycling round the spokes).
    var DEFAULTS = { spokes: 12, speed: 1000, thickness: 0.09, length: 0.46 };
    var SIZE = 2.2; // em

    var css =
      '.spk{position:relative;display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em}' +
      '.spk-arm{position:absolute;inset:0}' +
      '.spk-sp{position:absolute;left:50%;top:0;background:currentColor;opacity:.16}';
    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(6, Math.min(16, +d.spokes || DEFAULTS.spokes));
      var dots = d.shape === 'dot';
      var w = SIZE * Math.max(0.03, Math.min(0.25, +d.thickness || DEFAULTS.thickness));
      var len = (SIZE / 2) * Math.max(0.15, Math.min(0.8, +d.length || DEFAULTS.length));
      if (dots) { w = Math.max(w, SIZE * 0.13); len = w; }
      var colors = d.colors ? d.colors.split(',') : [];
      el.classList.add('spk');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var spokes = [];
      for (var i = 0; i < n; i++) {
        var arm = document.createElement('span');
        arm.className = 'spk-arm';
        arm.style.transform = 'rotate(' + (360 / n) * i + 'deg)';
        arm.setAttribute('aria-hidden', 'true');
        var sp = document.createElement('span');
        sp.className = 'spk-sp';
        sp.style.width = w + 'em';
        sp.style.height = len + 'em';
        sp.style.marginLeft = -w / 2 + 'em';
        sp.style.borderRadius = w / 2 + 'em';
        if (colors.length) sp.style.background = colors[i % colors.length].trim();
        arm.appendChild(sp);
        el.appendChild(arm);
        spokes.push(sp);
      }
      if (reduced()) {
        // A still frame: a bright head with a fading tail behind it.
        spokes.forEach(function (sp, i) { sp.style.opacity = Math.max(0.16, 1 - ((n - i) % n) / (n * 0.6)); });
        return null;
      }
      var speed = +d.speed || DEFAULTS.speed;
      // Each spoke flashes bright, then fades over most of the lap. The spokes
      // are wound on a step apart, so the bright one walks round the circle.
      var anims = spokes.map(function (sp, i) {
        var a = anime.animate(sp, {
          opacity: [
            { from: 1, to: 1, duration: 1 },
            { to: 0.16, duration: speed * 0.85, ease: 'out(1.6)' },
            { to: 0.16, duration: speed * 0.15 - 1 }
          ],
          loop: true,
          autoplay: false
        });
        a.seek(((n - i) % n) * (speed / n));
        return a;
      });
      return anims;
    }

    function init() {
      document.querySelectorAll('[data-spoke-spinner]').forEach(function (el) {
        if (el._spk) return;
        el._spk = 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-spoke-spinner style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>

<!-- dots instead of bars: -->
<div data-spoke-spinner data-shape="dot" data-spokes="8"></div>