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
- Paste the snippet just before
</head>. - Add
data-progress-ringanddata-value(0 to 100) to an empty<div>. - As things progress, set
data-valuefrom your code:ring.dataset.value = 72.
Options
Add any of these attributes to the same element.
data-max | The 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-track | Ring background colour. Default a faded copy of color. |
data-thickness | Line width as a share of the size. Default 0.12. |
data-duration | Time for each glide. Default 700 ms. |
data-loop | Fill 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.
<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><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>