Spinner Ring
An arc chases itself round a circle: the head races ahead, the tail sweeps round to catch it, and the whole ring keeps turning, so it never looks like it’s repeating. Give it a faint track or a new colour on every lap. Size it with font-size and colour it with color.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-spinner-ring>where the loader should appear.
Options
Add any of these attributes to the same element.
data-track | Show a faint full ring behind the arc. Give it a colour, or leave it empty for a faded copy of the arc’s. |
data-colors | Comma-separated colours: the arc takes the next one on every lap. |
data-thickness | Line width as a share of the size. Default 0.1. |
data-speed | Time for one lap. Default 1400 ms. |
It only spins while it’s on screen, and is marked as a progress bar labelled “Loading” (set your own aria-label). With reduced motion it shows a still three-quarter ring.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spinner Ring: an arc chases itself round a circle. Its head races ahead,
// its tail catches up, and the whole ring keeps turning, so it never looks
// like it's repeating. Sized with font-size, coloured with color.
// It spins while it's on screen.
// Usage: <div data-spinner-ring aria-label="Loading"></div>
// Optional: data-colors (comma-separated: the arc takes the next colour on
// every lap), data-track (a faint full ring behind the arc; give it a colour,
// or leave it empty for a faded copy of the arc's), data-thickness (as a
// share of the size, default 0.1), data-speed (ms for one lap, default 1400).
var DEFAULTS = { thickness: 0.1, speed: 1400 };
var css =
'.spr{display:inline-block;width:2.4em;height:2.4em;vertical-align:middle}' +
'.spr svg{display:block;width:100%;height:100%;overflow:visible}' +
'.spr circle{fill:none;stroke:currentColor}' +
'.spr-arc{stroke-linecap:round}' +
'.spr-track{opacity:.16}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function circle(cls, r, w) {
var c = document.createElementNS(NS, 'circle');
c.setAttribute('class', cls);
c.setAttribute('cx', 25);
c.setAttribute('cy', 25);
c.setAttribute('r', r);
c.setAttribute('stroke-width', w);
return c;
}
function build(el) {
var d = el.dataset;
var w = 50 * Math.max(0.02, Math.min(0.3, +d.thickness || DEFAULTS.thickness));
var r = 25 - w / 2;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
el.classList.add('spr');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 50 50');
svg.setAttribute('aria-hidden', 'true');
if (d.track !== undefined && d.track !== 'false') {
var track = circle('spr-track', r, w);
if (d.track) { track.style.stroke = d.track; track.style.opacity = 1; }
svg.appendChild(track);
}
var arc = circle('spr-arc', r, w);
if (colors.length) arc.style.stroke = colors[0];
svg.appendChild(arc);
el.appendChild(svg);
var line = anime.svg.createDrawable(arc);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// A still three-quarter ring still reads as "busy".
anime.utils.set(line, { draw: '0 0.72' });
svg.style.transform = 'rotate(-90deg)';
return null;
}
var speed = +d.speed || DEFAULTS.speed;
var lap = 0;
anime.utils.set(line, { draw: '0 0.04' });
// The head runs ahead to three-quarters of the ring, then the tail sweeps
// round to meet it. It ends where it started (1 is 0 again), so each lap
// joins the next seamlessly while the whole ring keeps turning.
var chase = anime.animate(line, {
draw: [
{ to: '0 0.74', duration: speed * 0.5, ease: 'inOut(2.4)' },
{ to: '0.96 1', duration: speed * 0.5, ease: 'inOut(2.4)' }
],
loop: true,
autoplay: false,
onLoop: function () {
// Change colour when the arc is at its smallest, so the switch hides.
if (colors.length > 1) arc.style.stroke = colors[++lap % colors.length];
}
});
var turn = anime.animate(svg, { rotate: [0, 360], duration: speed * 1.7, ease: 'linear', loop: true, autoplay: false });
return [chase, turn];
}
function init() {
document.querySelectorAll('[data-spinner-ring]').forEach(function (el) {
if (el._spr) return;
el._spr = true;
var anims = build(el);
if (!anims) return;
// Only spin while visible, to save battery.
new IntersectionObserver(function (entries) {
anims.forEach(function (a) { entries[0].isIntersecting ? a.play() : a.pause(); });
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-spinner-ring style="font-size: 24px; color: #6a0dad"></div>
<!-- with a track and changing colours: -->
<div data-spinner-ring data-track data-colors="#a855f7,#ec4899,#f59e0b" aria-label="Saving"></div>