← Numbers

Activity Rings

Three concentric rings fill towards their goals one after the other, like a fitness tracker. A ring that passes its goal keeps going round with a shadow at its tip, and a legend beside the rings counts each value up as it fills.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-activity-rings="420/500, 24/30, 12/12" to an empty element: each ring’s value and goal, outermost first. Size it with font-size; the rings are 8em across.

Options

Add any of these attributes to the same element.

data-legendShow each ring’s name and value beside the rings, counting up as they fill.
data-labelsRing names. Default Move|Exercise|Stand.
data-unitsUnits after each value. Default CAL|MIN|HRS.
data-colorsOne colour per ring. Default #fa1e5a,#9bf00b,#1fd9f0.
data-durationHow long each ring takes to fill. Default 1500 ms.
data-loopEmpty and refill every few seconds.
data-holdHow long the rings rest full before looping. Default 2600 ms.

Use one ring or as many as fit. The rings look their best on a dark background, as on a watch.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Activity Rings: concentric rings fill towards their goals one after the
    // other, like a fitness tracker. A ring that passes its goal keeps going
    // round, with a shadow at its tip so you can see it overlap, and an
    // optional legend counts each value up beside the rings.
    // Usage: <div data-activity-rings="420/500, 24/30, 12/12"></div>   (value/goal, outermost first)
    // Optional: data-labels (names, default "Move|Exercise|Stand"), data-units
    // (default "CAL|MIN|HRS"), data-legend (show the legend beside the rings),
    // data-colors (one per ring, default "#fa1e5a,#9bf00b,#1fd9f0"),
    // data-duration (ms per ring, default 1500), data-loop (empty and refill
    // every few seconds), data-hold (ms it rests full before looping, default 2600).
    // Size it with font-size: the rings are 8em across.
    var DEFAULTS = {
      rings: '420/500,24/30,12/12', labels: 'Move|Exercise|Stand', units: 'CAL|MIN|HRS',
      colors: '#fa1e5a,#9bf00b,#1fd9f0', duration: 1500, hold: 2600, stagger: 220
    };
    var NS = 'http://www.w3.org/2000/svg';
    var W = 9.4;                       // ring thickness in the 100-unit viewBox

    var css =
      '.acr{display:inline-flex;align-items:center;gap:1.1em;font-variant-numeric:tabular-nums}' +
      '.acr-rings{flex:none;width:8em;height:8em;display:block;overflow:visible}' +
      '.acr-tip{filter:drop-shadow(0 0 1.4px rgba(0,0,0,.55))}' +
      '.acr-legend{display:grid;gap:.55em;text-align:left;line-height:1.15}' +
      '.acr-row b{display:block;font-size:.72em;font-weight:600;letter-spacing:.02em;opacity:.8}' +
      '.acr-row span{display:block;font-size:1.25em;font-weight:800;letter-spacing:-.01em;white-space:nowrap}' +
      '.acr-row small{font-size:.62em;font-weight:700;margin-left:.15em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

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

    function list(s) { return s.split(/[|,]/).map(function (x) { return x.trim(); }); }

    // Point at angle a (0 = twelve o'clock, 1 = a full turn) on radius r.
    function at(r, a) {
      var t = (a - 0.25) * Math.PI * 2;
      return [50 + r * Math.cos(t), 50 + r * Math.sin(t)];
    }

    function setup(el) {
      var d = el.dataset;
      var spec = (el.getAttribute('data-activity-rings') || DEFAULTS.rings).split(/[,|]/);
      var labels = (d.labels || DEFAULTS.labels).split('|');
      var units = (d.units || DEFAULTS.units).split('|');
      var colors = list(d.colors || DEFAULTS.colors);
      var a = {
        el: el, rings: [], duration: +d.duration || DEFAULTS.duration, hold: +d.hold || DEFAULTS.hold,
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, timer: 0, anims: []
      };
      el.classList.add('acr');
      el.textContent = '';
      var s = svg('svg', { viewBox: '0 0 100 100', 'class': 'acr-rings', 'aria-hidden': 'true' }, el);
      var legend = null;
      if (d.legend !== undefined) {
        legend = document.createElement('div');
        legend.className = 'acr-legend';
        legend.setAttribute('aria-hidden', 'true');
        el.appendChild(legend);
      }
      var said = [];
      spec.forEach(function (part, i) {
        var m = part.split('/'), value = parseFloat(m[0]) || 0, goal = parseFloat(m[1]) || 100;
        var r = 50 - W / 2 - i * (W + 1.6);
        if (r < W) return;
        var col = colors[i % colors.length];
        var ring = { value: value, goal: goal, r: r, color: col, p: 0, label: labels[i] || '', unit: units[i] || '' };
        svg('circle', { cx: 50, cy: 50, r: r, fill: 'none', stroke: col, 'stroke-opacity': 0.2, 'stroke-width': W }, s);
        ring.full = svg('circle', { cx: 50, cy: 50, r: r, fill: 'none', stroke: col, 'stroke-width': W, opacity: 0 }, s);
        ring.arc = svg('path', { fill: 'none', stroke: col, 'stroke-width': W, 'stroke-linecap': 'round' }, s);
        ring.tip = svg('circle', { r: W / 2, fill: col, 'class': 'acr-tip', opacity: 0 }, s);
        // A small chevron at twelve o'clock showing which way the ring runs.
        var c = at(r, 0);
        ring.arrow = svg('path', {
          d: 'M' + (c[0] - 1.3) + ' ' + (c[1] - 2.4) + 'l2.4 2.4l-2.4 2.4',
          fill: 'none', stroke: '#000', 'stroke-opacity': 0.75, 'stroke-width': 1.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
        }, s);
        if (legend) {
          var row = document.createElement('div');
          row.className = 'acr-row';
          row.innerHTML = '<b></b><span style="color:' + col + '"></span>';
          row.querySelector('b').textContent = ring.label;
          legend.appendChild(row);
          ring.num = row.querySelector('span');
        }
        said.push((ring.label ? ring.label + ' ' : '') + value + ' of ' + goal + (ring.unit ? ' ' + ring.unit.toLowerCase() : ''));
        a.rings.push(ring);
      });
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', said.join(', '));
      a.rings.forEach(function (ring) { draw(ring, a.still ? ring.value / ring.goal : 0); });
      return a;
    }

    // p = value / goal; above 1 the ring laps itself.
    function draw(ring, p) {
      ring.p = p;
      var r = ring.r, lapped = p >= 1;
      var shown = Math.min(lapped ? p - Math.floor(p) : p, 0.9999);
      ring.full.setAttribute('opacity', lapped ? 1 : 0);
      if (shown <= 0.0005) {
        ring.arc.setAttribute('d', '');
      } else {
        var s = at(r, 0), e = at(r, shown);
        ring.arc.setAttribute('d', 'M' + s[0].toFixed(2) + ' ' + s[1].toFixed(2) +
          'A' + r + ' ' + r + ' 0 ' + (shown > 0.5 ? 1 : 0) + ' 1 ' + e[0].toFixed(2) + ' ' + e[1].toFixed(2));
      }
      // Past the goal, the tip casts a shadow over the start of the ring.
      var t = at(r, p);
      ring.tip.setAttribute('cx', t[0].toFixed(2));
      ring.tip.setAttribute('cy', t[1].toFixed(2));
      ring.tip.setAttribute('opacity', p > 0.92 ? Math.min(1, (p - 0.92) * 12) : 0);
      ring.arrow.setAttribute('opacity', p > 0.97 && p < 1.04 ? 0.4 : 1);
      if (ring.num) {
        var v = Math.round(ring.goal * p);
        ring.num.innerHTML = v + '/' + ring.goal + '<small>' + ring.unit + '</small>';
      }
    }

    function fill(a) {
      clearTimeout(a.timer);
      a.anims.forEach(function (x) { x.pause(); });
      a.anims = a.rings.map(function (ring, i) {
        var st = { p: ring.p };
        var target = ring.value / ring.goal;
        return anime.animate(st, {
          p: target, delay: i * DEFAULTS.stagger, duration: a.duration * Math.max(0.6, Math.min(1.6, target)),
          ease: 'out(3)', onUpdate: function () { draw(ring, st.p); }
        });
      });
      if (a.loop) {
        var longest = a.rings.length * DEFAULTS.stagger + a.duration * 1.6;
        a.timer = setTimeout(function () { empty(a); }, longest + a.hold);
      }
    }

    function empty(a) {
      a.anims.forEach(function (x) { x.pause(); });
      a.anims = a.rings.map(function (ring, i) {
        var st = { p: ring.p };
        return anime.animate(st, {
          p: 0, delay: i * 80, duration: 700, ease: 'inOut(2)', onUpdate: function () { draw(ring, st.p); }
        });
      });
      a.timer = setTimeout(function () { if (a.visible) fill(a); }, 700 + a.rings.length * 80 + 400);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var a = e.target._acr;
          var was = a.visible;
          a.visible = e.isIntersecting;
          if (a.visible && !was && (!a.started || a.loop)) {
            a.started = true;
            if (a.loop) { a.rings.forEach(function (r) { draw(r, 0); }); }
            fill(a);
          } else if (!a.visible && a.loop) {
            clearTimeout(a.timer);
            a.anims.forEach(function (x) { x.pause(); });
          }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-activity-rings]').forEach(function (el) {
        if (el._acr) return;
        var a = (el._acr = setup(el));
        if (!a.still) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-activity-rings="420/500, 24/30, 12/12" data-legend style="font-size: 20px"></div>

<!-- one ring for a savings goal: -->
<div data-activity-rings="750/1000" data-labels="Saved" data-units="GBP" data-colors="#22c55e" data-legend></div>