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
- Paste the snippet just before
</head>. - Add
data-activity-rings="420/500, 24/30, 12/12"to an empty element: each ring’s value and goal, outermost first. Size it withfont-size; the rings are 8em across.
Options
Add any of these attributes to the same element.
data-legend | Show each ring’s name and value beside the rings, counting up as they fill. |
data-labels | Ring names. Default Move|Exercise|Stand. |
data-units | Units after each value. Default CAL|MIN|HRS. |
data-colors | One colour per ring. Default #fa1e5a,#9bf00b,#1fd9f0. |
data-duration | How long each ring takes to fill. Default 1500 ms. |
data-loop | Empty and refill every few seconds. |
data-hold | How 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.
<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><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>