← Loaders

Spinner Ring

An arc chases itself round a circle: the head races ahead, the tail sweeps round to catch it, and the whole ring keeps turning, so it never looks like it’s repeating. Give it a faint track or a new colour on every lap. 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-spinner-ring> where the loader should appear.

Options

Add any of these attributes to the same element.

data-trackShow a faint full ring behind the arc. Give it a colour, or leave it empty for a faded copy of the arc’s.
data-colorsComma-separated colours: the arc takes the next one on every lap.
data-thicknessLine width as a share of the size. Default 0.1.
data-speedTime for one lap. Default 1400 ms.

It only spins while it’s on screen, and is marked as a progress bar labelled “Loading” (set your own aria-label). With reduced motion it shows a still three-quarter ring.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spinner Ring: an arc chases itself round a circle. Its head races ahead,
    // its tail catches up, and the whole ring keeps turning, so it never looks
    // like it's repeating. Sized with font-size, coloured with color.
    // It spins while it's on screen.
    // Usage: <div data-spinner-ring aria-label="Loading"></div>
    // Optional: data-colors (comma-separated: the arc takes the next colour on
    // every lap), data-track (a faint full ring behind the arc; give it a colour,
    // or leave it empty for a faded copy of the arc's), data-thickness (as a
    // share of the size, default 0.1), data-speed (ms for one lap, default 1400).
    var DEFAULTS = { thickness: 0.1, speed: 1400 };

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

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

    function circle(cls, r, w) {
      var c = document.createElementNS(NS, 'circle');
      c.setAttribute('class', cls);
      c.setAttribute('cx', 25);
      c.setAttribute('cy', 25);
      c.setAttribute('r', r);
      c.setAttribute('stroke-width', w);
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var w = 50 * Math.max(0.02, Math.min(0.3, +d.thickness || DEFAULTS.thickness));
      var r = 25 - w / 2;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
      el.classList.add('spr');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('viewBox', '0 0 50 50');
      svg.setAttribute('aria-hidden', 'true');
      if (d.track !== undefined && d.track !== 'false') {
        var track = circle('spr-track', r, w);
        if (d.track) { track.style.stroke = d.track; track.style.opacity = 1; }
        svg.appendChild(track);
      }
      var arc = circle('spr-arc', r, w);
      if (colors.length) arc.style.stroke = colors[0];
      svg.appendChild(arc);
      el.appendChild(svg);
      var line = anime.svg.createDrawable(arc);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        // A still three-quarter ring still reads as "busy".
        anime.utils.set(line, { draw: '0 0.72' });
        svg.style.transform = 'rotate(-90deg)';
        return null;
      }

      var speed = +d.speed || DEFAULTS.speed;
      var lap = 0;
      anime.utils.set(line, { draw: '0 0.04' });
      // The head runs ahead to three-quarters of the ring, then the tail sweeps
      // round to meet it. It ends where it started (1 is 0 again), so each lap
      // joins the next seamlessly while the whole ring keeps turning.
      var chase = anime.animate(line, {
        draw: [
          { to: '0 0.74', duration: speed * 0.5, ease: 'inOut(2.4)' },
          { to: '0.96 1', duration: speed * 0.5, ease: 'inOut(2.4)' }
        ],
        loop: true,
        autoplay: false,
        onLoop: function () {
          // Change colour when the arc is at its smallest, so the switch hides.
          if (colors.length > 1) arc.style.stroke = colors[++lap % colors.length];
        }
      });
      var turn = anime.animate(svg, { rotate: [0, 360], duration: speed * 1.7, ease: 'linear', loop: true, autoplay: false });
      return [chase, turn];
    }

    function init() {
      document.querySelectorAll('[data-spinner-ring]').forEach(function (el) {
        if (el._spr) return;
        el._spr = true;
        var anims = build(el);
        if (!anims) return;
        // Only spin while visible, to save battery.
        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-spinner-ring style="font-size: 24px; color: #6a0dad"></div>

<!-- with a track and changing colours: -->
<div data-spinner-ring data-track data-colors="#a855f7,#ec4899,#f59e0b" aria-label="Saving"></div>