← Loaders

Progress Ring

A small circular progress indicator, like the one on a download button. The ring glides round to its value and, at 100%, gives a little pop, fills with colour and a tick draws itself in the middle. For a big ring with a counting number, see Progress Ring with Value.

report.pdf2.4 MB

slides.key18 MB

track.mp36.1 MB

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-progress-ring and data-value (0 to 100) to an empty <div>.
  3. As things progress, set data-value from your code: ring.dataset.value = 72.

Options

Add any of these attributes to the same element.

data-maxThe value of a full ring. Default 100.
data-icon="download"An arrow in the middle that bobs while it works.
data-done="false"No pop and tick at 100%.
data-trackRing background colour. Default a faded copy of color.
data-thicknessLine width as a share of the size. Default 0.12.
data-durationTime for each glide. Default 700 ms.
data-loopFill in uneven hops like a real download, tick, hold and start again. Handy for demos.

Size it with font-size (it’s 2.4em across) and colour it with color. It’s a real progressbar for screen readers, with aria-valuenow kept up to date; give it an aria-label.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Progress Ring: a small circular progress indicator, like the one on a
    // download button. The ring glides round to its value; at 100% it gives a
    // little pop, fills with colour and a tick draws itself in the middle.
    // Change data-value from your code and it glides to the new value.
    // It's a real progressbar for screen readers, with its value kept up to date.
    // Sized with font-size (it's 2.4em across), coloured with color.
    // Usage: <div data-progress-ring data-value="40" aria-label="Downloading report.pdf"></div>
    // Optional: data-max (default 100), data-icon="download" (an arrow in the
    // middle that bobs while it works), data-done="false" (no tick at 100%),
    // data-track (ring background, default a faded copy of color), data-thickness
    // (as a share of the size, default 0.12), data-duration (ms per glide,
    // default 700), data-loop (it fills in uneven hops like a real download,
    // ticks, holds and starts again; handy for demos).
    var DEFAULTS = { thickness: 0.12, duration: 700, hold: 1500 };

    var css =
      '.pgr{display:inline-block;width:2.4em;height:2.4em;vertical-align:middle}' +
      '.pgr svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.pgr-track,.pgr-arc{fill:none;stroke:currentColor}' +
      '.pgr-track{opacity:.16}' +
      '.pgr-arc{stroke-linecap:round}' +
      '.pgr-disc{fill:currentColor;transform-box:fill-box;transform-origin:center;transform:scale(0)}' +
      '.pgr-icon,.pgr-tick{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}' +
      '.pgr-tick{stroke:#fff}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };
    var on = function (v) { return v !== undefined && v !== 'false'; };

    function build(el) {
      var d = el.dataset;
      var w = 40 * Math.max(0.04, Math.min(0.25, +d.thickness || DEFAULTS.thickness));
      var r = 20 - w / 2;
      el.classList.add('pgr');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Progress');
      var max = parseFloat(d.max) || 100;
      el.setAttribute('aria-valuemin', 0);
      el.setAttribute('aria-valuemax', max);
      el.innerHTML =
        '<svg viewBox="0 0 40 40" aria-hidden="true">' +
        '<circle class="pgr-track" cx="20" cy="20" r="' + r + '" stroke-width="' + w + '"/>' +
        '<circle class="pgr-disc" cx="20" cy="20" r="' + (r + w / 2) + '"/>' +
        // Start the ring at 12 o'clock.
        '<circle class="pgr-arc" cx="20" cy="20" r="' + r + '" stroke-width="' + w + '" transform="rotate(-90 20 20)"/>' +
        (d.icon === 'download' ? '<g><path class="pgr-icon" d="M20 13v13M14.5 21l5.5 5.5 5.5-5.5" stroke-width="' + w * 0.6 + '"/></g>' : '') +
        '<path class="pgr-tick" d="M13.5 20.5l4.5 4.5 8.5-9" stroke-width="' + w * 0.75 + '"/>' +
        '</svg>';
      var svg = el.firstChild;
      if (d.track) { svg.querySelector('.pgr-track').style.stroke = d.track; svg.querySelector('.pgr-track').style.opacity = 1; }
      var p = {
        el: el, svg: svg, max: max, done: false, seen: false,
        arc: anime.svg.createDrawable(svg.querySelector('.pgr-arc')),
        tick: anime.svg.createDrawable(svg.querySelector('.pgr-tick')),
        disc: svg.querySelector('.pgr-disc'), icon: svg.querySelector('.pgr-icon')
      };
      anime.utils.set(p.arc, { draw: '0 0' });
      anime.utils.set(p.tick, { draw: '0 0' });
      return p;
    }

    function target(p) {
      return Math.max(0, Math.min(p.max, parseFloat(p.el.dataset.value) || 0));
    }

    // At 100%: pop, flood with colour, draw the tick. Below it again: undo.
    function finish(p, done) {
      if (p.done === done || p.el.dataset.done === 'false') return;
      p.done = done;
      if (reduced()) {
        p.disc.style.transform = done ? 'scale(1)' : 'scale(0)';
        anime.utils.set(p.tick, { draw: done ? '0 1' : '0 0' });
        if (p.icon) p.icon.style.opacity = done ? 0 : 1;
        return;
      }
      if (done) {
        anime.animate(p.svg, { scale: [{ to: 1.14, duration: 140, ease: 'out(2)' }, { to: 1, duration: 650, ease: 'outElastic(1, .45)' }] });
        anime.animate(p.disc, { scale: [0, 1], duration: 420, ease: 'out(3)' });
        if (p.icon) anime.animate(p.icon, { opacity: 0, translateY: 4, duration: 200, ease: 'in(2)' });
        anime.animate(p.tick, { draw: '0 1', duration: 420, delay: 180, ease: 'out(3)' });
      } else {
        anime.animate(p.disc, { scale: 0, duration: 350, ease: 'inOut(2)' });
        anime.animate(p.tick, { draw: '1 1', duration: 250, ease: 'in(2)', onComplete: function () { anime.utils.set(p.tick, { draw: '0 0' }); } });
        if (p.icon) anime.animate(p.icon, { opacity: [0, 1], translateY: [-4, 0], duration: 350, delay: 200, ease: 'out(3)' });
      }
    }

    function glide(p) {
      var to = target(p);
      p.el.setAttribute('aria-valuenow', Math.round(to * 100) / 100);
      var full = to >= p.max;
      if (!full) finish(p, false);
      if (reduced()) { anime.utils.set(p.arc, { draw: '0 ' + to / p.max }); finish(p, full); return; }
      anime.animate(p.arc, {
        draw: '0 ' + to / p.max,
        duration: +p.el.dataset.duration || DEFAULTS.duration,
        ease: 'out(3)',
        onComplete: function () { if (full) finish(p, true); }
      });
    }

    // data-loop: hop up to full in uneven steps, tick, hold, empty, repeat.
    function demo(p) {
      var v = target(p);
      var next = v >= p.max ? 0 : Math.min(p.max, v + p.max * (0.1 + Math.random() * 0.32));
      var wait = (+p.el.dataset.duration || DEFAULTS.duration) * 0.7 +
        (v >= p.max ? DEFAULTS.hold : v === 0 ? 700 : 150 + Math.random() * 650);
      p.timer = setTimeout(function () {
        p.el.dataset.value = Math.round(next);
        demo(p);
      }, wait);
    }

    function start(p) {
      var d = p.el.dataset;
      if (p.icon && !reduced() && !p.bob) {
        p.bob = anime.animate(p.icon.parentNode, { translateY: [-1.5, 1.5], duration: 650, ease: 'inOut(2)', loop: true, alternate: true });
      } else if (p.bob) p.bob.play();
      if (!p.seen) { p.seen = true; glide(p); }
      if (on(d.loop) && !reduced() && !p.timer) demo(p);
    }

    function stop(p) {
      if (p.bob) p.bob.pause();
      clearTimeout(p.timer);
      p.timer = null;
    }

    function init() {
      document.querySelectorAll('[data-progress-ring]').forEach(function (el) {
        if (el._pgr) return;
        var p = (el._pgr = build(el));
        // Glide whenever data-value changes (once it has been seen).
        new MutationObserver(function () { if (p.seen) glide(p); })
          .observe(el, { attributes: true, attributeFilter: ['data-value'] });
        // Start on screen; pause the demo loop when off screen.
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? start(p) : stop(p);
        }, { threshold: 0.3 }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-progress-ring data-value="40" data-icon="download" aria-label="Downloading report.pdf"
     style="font-size: 20px; color: #6a0dad"></div>
<script>
  // later:
  document.querySelector("[data-progress-ring]").dataset.value = 100;
</script>