← Loaders

Concentric Arcs

Arcs on nested rings spin round a shared centre, each at its own speed, neighbours turning opposite ways. Their lengths breathe in and out as they go, so the pattern keeps shifting and never quite repeats. 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-concentric-arcs> where the loader should appear.

Options

Add any of these attributes to the same element.

data-ringsNumber of rings, 2 to 6. Default 4.
data-speedTime for one lap of the outer ring; the others go faster or slower. Default 1400 ms.
data-colorsComma-separated colours, one per ring from the outside in. Without it the inner rings are paler shades of color.
data-thicknessLine width as a share of the gap between rings, 0.2 to 0.9. Default 0.55.
data-trackShow a faint full ring behind each arc.

It’s 2.4em across. It only spins while it’s on screen. With reduced motion the arcs sit still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Concentric Arcs: arcs on nested rings spin round a shared centre, each
    // at its own speed, neighbours turning opposite ways, their lengths
    // breathing in and out so the pattern never quite repeats.
    // Sized with font-size (2.4em across), coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-concentric-arcs aria-label="Loading"></div>
    // Optional: data-rings (2 to 6, default 4), data-speed (ms for one lap of
    // the outer ring, default 1400), data-colors (comma-separated, one per
    // ring from the outside in), data-thickness (share of the gap between
    // rings, 0.2 to 0.9, default 0.55), data-track (show a faint full ring
    // behind each arc).
    var DEFAULTS = { rings: 4, speed: 1400, thickness: 0.55 };
    var SIZE = 2.4; // em
    // Lap time of each ring relative to the outer one; negative turns the other way.
    var LAPS = [1, -0.72, 1.35, -0.58, 1.1, -0.85];

    var css =
      '.cca{display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em;vertical-align:middle}' +
      '.cca svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.cca-arc,.cca-track{fill:none;stroke:currentColor;stroke-linecap:round}' +
      '.cca-track{opacity:.12}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';

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

    function make(parent, tag, attrs) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    function build(el) {
      var d = el.dataset;
      var n = Math.max(2, Math.min(6, Math.round(+d.rings) || DEFAULTS.rings));
      var th = Math.max(0.2, Math.min(0.9, +d.thickness || DEFAULTS.thickness));
      var colors = (d.colors || '').split(',').map(function (c) { return c.trim(); }).filter(Boolean);
      var track = d.track !== undefined && d.track !== 'false';
      el.classList.add('cca');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';

      var svg = make(el, 'svg', { viewBox: '-25 -25 50 50', 'aria-hidden': 'true' });
      var outer = 22, inner = 4.5;
      var gap = (outer - inner) / (n - 0.5);
      var w = gap * th;
      var rings = [];
      for (var i = 0; i < n; i++) {
        var r = outer - i * gap;
        var col = colors.length ? colors[i % colors.length] : '';
        if (track) {
          var t = make(svg, 'circle', { class: 'cca-track', r: r.toFixed(2), 'stroke-width': w.toFixed(2) });
          if (col) t.style.stroke = col;
        }
        var arc = make(svg, 'circle', { class: 'cca-arc', r: r.toFixed(2), 'stroke-width': w.toFixed(2), pathLength: 100 });
        if (col) arc.style.stroke = col;
        else arc.style.opacity = (1 - i * (0.45 / (n - 1))).toFixed(2);
        rings.push({ el: arc, lap: LAPS[i], base: 26 + (i % 3) * 9, phase: i * 1.7, start: i * 47 });
      }

      function draw(ms, speed) {
        rings.forEach(function (g) {
          var turns = ms / (speed * Math.abs(g.lap));
          var deg = (g.start + (g.lap > 0 ? 1 : -1) * turns * 360) % 360;
          var len = g.base + 14 * Math.sin(ms / (speed * 1.3) * Math.PI + g.phase);
          g.el.setAttribute('stroke-dasharray', len.toFixed(2) + ' ' + (100 - len).toFixed(2));
          g.el.setAttribute('transform', 'rotate(' + deg.toFixed(2) + ')');
        });
      }

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

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

<!-- five rings in four colours, with tracks: -->
<div data-concentric-arcs data-rings="5" data-track data-colors="#a855f7,#ec4899,#f59e0b,#10b981"></div>