Donut Fill
A ring sweeps round from twelve o’clock to its percentage while the number in the middle counts up to match. Perfect for goals, scores and progress.
How to use it
- Paste the snippet just before
</head>. - Add
data-donut-fill="72"(the percentage) to an empty element and give it a width.
Options
Add any of these attributes to the same element.
data-label | Small text under the number. |
data-color | Ring colour. Default #a855f7. |
data-track | Colour of the empty part. Default #ece6f6. |
data-thickness | Ring thickness as a share of the size. Default 0.12. |
data-duration | Time to fill. Default 1600 ms. |
data-loop | Empty and refill every few seconds. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Donut Fill: a ring sweeps round from twelve o'clock to its percentage
// while the number in the middle counts up to match.
// Usage: <div data-donut-fill="72" style="width:160px"></div>
// Optional: data-label (small text under the number), data-color (default
// #a855f7), data-track (ring background, default #ece6f6), data-thickness
// (as a share of the size, default 0.12), data-duration (ms, default 1600),
// data-loop (empty and refill every few seconds).
var DEFAULTS = { color: '#a855f7', track: '#ece6f6', thickness: 0.12, duration: 1600, hold: 2400 };
var css =
'.dfx{position:relative;display:inline-grid;place-items:center;aspect-ratio:1;width:160px;font-variant-numeric:tabular-nums}' +
'.dfx svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}' +
'.dfx-num{font-weight:800;letter-spacing:-0.03em;line-height:1}' +
'.dfx-mid{display:flex;flex-direction:column;align-items:center;gap:0.25em}' +
'.dfx-label{font-weight:500;opacity:.65;letter-spacing:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var value = Math.max(0, Math.min(100, parseFloat(el.getAttribute('data-donut-fill')) || 0));
var thick = +d.thickness || DEFAULTS.thickness;
var sw = thick * 100, r = 50 - sw / 2;
el.classList.add('dfx');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', value + '%' + (d.label ? ' ' + d.label : ''));
el.innerHTML =
'<svg viewBox="0 0 100 100" aria-hidden="true">' +
'<circle cx="50" cy="50" r="' + r + '" fill="none" stroke="' + (d.track || DEFAULTS.track) + '" stroke-width="' + sw + '"/>' +
'<circle class="dfx-arc" cx="50" cy="50" r="' + r + '" fill="none" stroke="' + (d.color || DEFAULTS.color) + '" stroke-width="' + sw +
'" stroke-linecap="round" pathLength="100" stroke-dasharray="100 100"/>' +
'</svg><div class="dfx-mid" aria-hidden="true"><span class="dfx-num"></span>' +
(d.label ? '<span class="dfx-label"></span>' : '') + '</div>';
if (d.label) el.querySelector('.dfx-label').textContent = d.label;
var c = { el: el, value: value, arc: el.querySelector('.dfx-arc'), num: el.querySelector('.dfx-num'), state: { v: 0 } };
// Size the number from the donut, so it scales with any width.
var label = el.querySelector('.dfx-label');
var fit = function () {
c.num.style.fontSize = el.clientWidth * 0.22 + 'px';
if (label) label.style.fontSize = Math.max(11, el.clientWidth * 0.085) + 'px';
};
fit();
if (window.ResizeObserver) new ResizeObserver(fit).observe(el);
return c;
}
function render(c) {
var v = c.state.v;
// A round cap pokes out past the end, so hide the arc completely at 0.
c.arc.style.strokeDashoffset = 100 - v;
c.arc.style.opacity = v > 0.3 ? 1 : 0;
c.num.textContent = Math.round(v) + '%';
}
function fill(c) {
var d = c.el.dataset;
var duration = +d.duration || DEFAULTS.duration;
anime.animate(c.state, {
v: [0, c.value], duration: duration, ease: 'out(3)', onUpdate: function () { render(c); },
onComplete: function () {
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
anime.animate(c.state, { v: 0, duration: 600, 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._dfx);
});
}, { threshold: 0.4 });
document.querySelectorAll('[data-donut-fill]').forEach(function (el) {
if (el._dfx) return;
var c = (el._dfx = build(el));
if (reduced) { c.state.v = c.value; render(c); return; }
render(c);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-donut-fill="72" data-label="complete" style="width: 160px"></div>