← Numbers

Progress Ring with Value

A gradient ring sweeps round towards a goal while the number in its centre counts up to match, with a glowing knob riding the end of the arc. It keeps your formatting, so steps, money and minutes all read naturally.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-progress-ring-value="8,420" (the value, written as it should read) and data-max="10,000" (the goal) to an empty element, and give it a width.

Options

Add any of these attributes to the same element.

data-maxThe goal: the value that fills the whole ring. Default 100.
data-labelSmall text under the number, e.g. of 10,000 steps.
data-colorOne colour, or two for a gradient along the ring. Default #a855f7,#ec4899.
data-trackColour of the empty part. Default #ece6f6.
data-thicknessRing thickness as a share of the size. Default 0.11.
data-durationTime to fill. Default 1800 ms.
data-loopEmpty and refill every few seconds.

Currency signs, commas, decimals and units like % or min are kept while it counts.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Progress Ring with Value: a gradient ring sweeps round towards a goal while
    // the number in its centre counts up, keeping your formatting ($, commas,
    // decimals, %). A glowing knob rides the end of the ring.
    // Usage: <div data-progress-ring-value="8,420" data-max="10,000" data-label="of 10,000 steps"></div>
    //    or: <div data-progress-ring-value="72%"></div>   (out of 100)
    // Optional: data-max (the goal: a full ring, default 100), data-label (small
    // text under the number), data-color (one colour, or two for a gradient,
    // e.g. "#a855f7,#ec4899"), data-track (ring background, default #ece6f6),
    // data-thickness (as a share of the size, default 0.11), data-duration (ms,
    // default 1800), data-loop (empty and refill every few seconds).
    // Size it with width; the number scales to fit.
    var DEFAULTS = { color: '#a855f7,#ec4899', track: '#ece6f6', thickness: 0.11, duration: 1800, hold: 2400 };

    var css =
      '.prv{position:relative;display:inline-grid;place-items:center;aspect-ratio:1;width:180px;font-variant-numeric:tabular-nums}' +
      '.prv-track,.prv-arc{position:absolute;inset:0;border-radius:50%;' +
      '-webkit-mask:radial-gradient(farthest-side,transparent calc(99.5% - var(--prv-w)),#000 calc(100% - var(--prv-w)));' +
      'mask:radial-gradient(farthest-side,transparent calc(99.5% - var(--prv-w)),#000 calc(100% - var(--prv-w)))}' +
      // The gradient runs from the start of the ring to wherever its end is now.
      '.prv-arc{background:conic-gradient(var(--prv-a) 0turn,var(--prv-b) calc(var(--prv-p) * 1turn),transparent 0)}' +
      '.prv-cap,.prv-knob{position:absolute;inset:0;pointer-events:none}' +
      '.prv-cap::before,.prv-knob::before{content:"";position:absolute;left:50%;top:0;width:var(--prv-w);height:var(--prv-w);margin-left:calc(var(--prv-w) / -2);border-radius:50%}' +
      '.prv-cap::before{background:var(--prv-a)}' +
      '.prv-knob::before{background:var(--prv-b);box-shadow:0 0 0 calc(var(--prv-w) * 0.22) #fff inset,0 0 calc(var(--prv-w) * 0.9) var(--prv-b)}' +
      '.prv-mid{position:relative;display:flex;flex-direction:column;align-items:center;gap:0.3em;max-width:72%;text-align:center}' +
      '.prv-num{font-weight:800;letter-spacing:-0.03em;line-height:1}' +
      '.prv-label{font-weight:500;opacity:.65;letter-spacing:0;line-height:1.2}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // "$8,420.50" → its prefix, number, decimals and suffix, so counting keeps the format.
    function parse(text) {
      var m = String(text).trim().match(/^(\D*?)(-?[\d,]*\.?\d+)(.*)$/);
      if (!m) return null;
      var num = m[2];
      return {
        prefix: m[1], suffix: m[3], value: parseFloat(num.replace(/,/g, '')),
        decimals: num.indexOf('.') >= 0 ? num.split('.')[1].length : 0, commas: num.indexOf(',') >= 0
      };
    }

    function format(n, f) {
      var s = n.toFixed(f.decimals);
      if (f.commas) {
        var parts = s.split('.');
        parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
        s = parts.join('.');
      }
      return f.prefix + s + f.suffix;
    }

    function build(el) {
      var d = el.dataset;
      var f = parse(el.getAttribute('data-progress-ring-value'));
      if (!f) return null;
      var max = d.max ? parseFloat(d.max.replace(/[^\d.]/g, '')) || 100 : 100;
      var colors = (d.color || DEFAULTS.color).split(',');
      el.classList.add('prv');
      el.style.setProperty('--prv-a', colors[0].trim());
      el.style.setProperty('--prv-b', (colors[1] || colors[0]).trim());
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', format(f.value, f) + (d.label ? ' ' + d.label : ''));
      el.innerHTML =
        '<div class="prv-track"></div><div class="prv-arc"></div><div class="prv-cap"></div><div class="prv-knob"></div>' +
        '<div class="prv-mid" aria-hidden="true"><span class="prv-num"></span>' + (d.label ? '<span class="prv-label"></span>' : '') + '</div>';
      el.querySelector('.prv-track').style.background = d.track || DEFAULTS.track;
      if (d.label) el.querySelector('.prv-label').textContent = d.label;
      var c = {
        el: el, f: f, share: Math.max(0, Math.min(1, f.value / max)),
        arc: el.querySelector('.prv-arc'), cap: el.querySelector('.prv-cap'), knob: el.querySelector('.prv-knob'),
        num: el.querySelector('.prv-num'), state: { p: 0 }
      };
      // Size the ring and number from the element, so it scales with any width.
      var label = el.querySelector('.prv-label');
      var fit = function () {
        var w = el.clientWidth;
        el.style.setProperty('--prv-w', w * (+d.thickness || DEFAULTS.thickness) + 'px');
        c.num.style.fontSize = w * (format(f.value, f).length > 5 ? 0.17 : 0.21) + 'px';
        if (label) label.style.fontSize = Math.max(11, w * 0.07) + 'px';
      };
      fit();
      if (window.ResizeObserver) new ResizeObserver(fit).observe(el);
      return c;
    }

    // p runs from 0 to 1 as the ring fills to its share of the goal.
    function render(c) {
      var share = c.share * c.state.p;
      c.el.style.setProperty('--prv-p', share);
      c.knob.style.transform = 'rotate(' + share * 360 + 'deg)';
      var show = share > 0.002 ? 1 : 0;
      c.cap.style.opacity = show;
      c.knob.style.opacity = show;
      c.num.textContent = format(c.f.value * c.state.p, c.f);
    }

    function fill(c) {
      var d = c.el.dataset;
      anime.animate(c.state, {
        p: [0, 1], duration: +d.duration || DEFAULTS.duration, ease: 'inOut(3)', onUpdate: function () { render(c); },
        onComplete: function () {
          if (d.loop === undefined || d.loop === 'false') return;
          setTimeout(function () {
            anime.animate(c.state, {
              p: 0, duration: 700, ease: 'inOut(2)', onUpdate: function () { render(c); },
              onComplete: function () { setTimeout(function () { fill(c); }, 300); }
            });
          }, DEFAULTS.hold);
        }
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          fill(e.target._prv);
        });
      }, { threshold: 0.4 });
      document.querySelectorAll('[data-progress-ring-value]').forEach(function (el) {
        if (el._prv) return;
        var c = (el._prv = build(el));
        if (!c) return;
        if (reduced) { c.state.p = 1; render(c); return; }
        render(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-progress-ring-value="8,420" data-max="10,000"
     data-label="of 10,000 steps" style="width: 180px"></div>

<!-- a percentage, in blue: -->
<div data-progress-ring-value="72%" data-color="#38bdf8,#0369a1" style="width: 140px"></div>