← Loaders

Spiral Dots

A ring of dots winds in towards the centre along a spiral, shrinking and fading as it goes, then unwinds back out again. Each dot runs a beat behind the last, so the ring curls into a turning spiral arm. 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-spiral-dots> where the loader should appear.

Options

Add any of these attributes to the same element.

data-dotsNumber of dots, 6 to 24. Default 12.
data-speedTime to wind in and back out. Default 2400 ms.
data-turnHow far the spiral winds as the dots go in, in turns. Default 0.6.
data-colorsComma-separated colours: the dots step through them in turn.

It’s 2.8em across. It only moves while it’s on screen. With reduced motion the dots sit still in a spiral.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spiral Dots: a ring of dots winds in towards the centre along a spiral,
    // shrinking and fading as it goes, then unwinds back out again, each dot a
    // beat behind the last so the ring turns into a curling spiral arm.
    // Sized with font-size (2.8em across), coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-spiral-dots aria-label="Loading"></div>
    // Optional: data-dots (6 to 24, default 12), data-speed (ms to wind in and
    // out once, default 2400), data-turn (how far the spiral winds, in turns,
    // default 0.6), data-colors (comma-separated; the dots step through them).
    var DEFAULTS = { dots: 12, speed: 2400, turn: 0.6 };
    var SIZE = 2.8, DOT = 0.3; // em
    var SPREAD = 0.3; // how far behind the first dot the last one runs, in cycles

    var css =
      '.spi{position:relative;display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em;vertical-align:middle}' +
      '.spi-d{position:absolute;top:50%;left:50%;width:' + DOT + 'em;height:' + DOT + 'em;margin:' + -DOT / 2 + 'em 0 0 ' + -DOT / 2 + 'em;border-radius:50%;background:currentColor;will-change:transform,opacity}';
    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(24, Math.round(+d.dots) || DEFAULTS.dots));
      var turn = d.turn != null && !isNaN(+d.turn) ? +d.turn : DEFAULTS.turn;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
      el.classList.add('spi');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var dots = [];
      for (var i = 0; i < n; i++) {
        var s = document.createElement('span');
        s.className = 'spi-d';
        if (colors.length) s.style.background = colors[i % colors.length];
        s.setAttribute('aria-hidden', 'true');
        el.appendChild(s);
        dots.push(s);
      }
      var R = SIZE / 2 - DOT / 2;
      var TAU = Math.PI * 2;

      function draw(p) {
        // p counts cycles. Each dot runs a little behind the one before, so as
        // the ring winds in it curls into a spiral rather than shrinking evenly.
        dots.forEach(function (dot, i) {
          var lag = i / n * SPREAD;
          var k = 0.5 + 0.5 * Math.cos(TAU * (p - lag));  // 1 = outside, 0 = centre
          var r = R * (0.08 + 0.92 * k);
          var a = i / n * TAU + p * TAU * 0.25 + (1 - k) * turn * TAU;
          var x = Math.cos(a) * r, y = Math.sin(a) * r;
          dot.style.transform = 'translate(' + x.toFixed(3) + 'em,' + y.toFixed(3) + 'em) scale(' + (0.4 + 0.6 * k).toFixed(3) + ')';
          dot.style.opacity = (0.35 + 0.65 * k).toFixed(3);
        });
      }

      draw(0.3);
      if (reduced()) return null;
      var speed = Math.max(400, +d.speed || DEFAULTS.speed);
      var t = 0.3 * speed, last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          draw(t / speed);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-spiral-dots]').forEach(function (el) {
        if (el._spi) return;
        el._spi = true;
        var timer = build(el);
        if (!timer) return;
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? timer.play() : timer.pause();
        }).observe(el);
      });
    }

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