Spoke Spinner
The classic spinner: a ring of spokes that light up one after another round the circle, each fading as the next one brightens. Swap the bars for dots, or change the number of spokes. 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-spoke-spinner>where the loader should appear.
Options
Add any of these attributes to the same element.
data-spokes | Number of spokes, 6 to 16. Default 12. |
data-speed | Time for one lap. Default 1000 ms. |
data-thickness | Spoke width as a share of the size. Default 0.09. |
data-length | Spoke length as a share of the radius. Default 0.46. |
data-shape="dot" | Round dots instead of bars. |
data-colors | Comma-separated colours, cycling round the spokes. |
It only spins while it’s on screen. With reduced motion it shows a still frame, a bright spoke with a fading tail.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spoke Spinner: the classic spinner, a ring of spokes that light up one
// after another round the circle, each fading as the next one brightens.
// Sized with font-size, coloured with color.
// It only spins while it's on screen.
// Usage: <div data-spoke-spinner aria-label="Loading"></div>
// Optional: data-spokes (6 to 16, default 12), data-speed (ms for one lap,
// default 1000), data-thickness (spoke width as a share of the size,
// default 0.09), data-length (spoke length as a share of the radius,
// default 0.46), data-shape="dot" (round dots instead of bars),
// data-colors (comma-separated, cycling round the spokes).
var DEFAULTS = { spokes: 12, speed: 1000, thickness: 0.09, length: 0.46 };
var SIZE = 2.2; // em
var css =
'.spk{position:relative;display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em}' +
'.spk-arm{position:absolute;inset:0}' +
'.spk-sp{position:absolute;left:50%;top:0;background:currentColor;opacity:.16}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var d = el.dataset;
var n = Math.max(6, Math.min(16, +d.spokes || DEFAULTS.spokes));
var dots = d.shape === 'dot';
var w = SIZE * Math.max(0.03, Math.min(0.25, +d.thickness || DEFAULTS.thickness));
var len = (SIZE / 2) * Math.max(0.15, Math.min(0.8, +d.length || DEFAULTS.length));
if (dots) { w = Math.max(w, SIZE * 0.13); len = w; }
var colors = d.colors ? d.colors.split(',') : [];
el.classList.add('spk');
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
var spokes = [];
for (var i = 0; i < n; i++) {
var arm = document.createElement('span');
arm.className = 'spk-arm';
arm.style.transform = 'rotate(' + (360 / n) * i + 'deg)';
arm.setAttribute('aria-hidden', 'true');
var sp = document.createElement('span');
sp.className = 'spk-sp';
sp.style.width = w + 'em';
sp.style.height = len + 'em';
sp.style.marginLeft = -w / 2 + 'em';
sp.style.borderRadius = w / 2 + 'em';
if (colors.length) sp.style.background = colors[i % colors.length].trim();
arm.appendChild(sp);
el.appendChild(arm);
spokes.push(sp);
}
if (reduced()) {
// A still frame: a bright head with a fading tail behind it.
spokes.forEach(function (sp, i) { sp.style.opacity = Math.max(0.16, 1 - ((n - i) % n) / (n * 0.6)); });
return null;
}
var speed = +d.speed || DEFAULTS.speed;
// Each spoke flashes bright, then fades over most of the lap. The spokes
// are wound on a step apart, so the bright one walks round the circle.
var anims = spokes.map(function (sp, i) {
var a = anime.animate(sp, {
opacity: [
{ from: 1, to: 1, duration: 1 },
{ to: 0.16, duration: speed * 0.85, ease: 'out(1.6)' },
{ to: 0.16, duration: speed * 0.15 - 1 }
],
loop: true,
autoplay: false
});
a.seek(((n - i) % n) * (speed / n));
return a;
});
return anims;
}
function init() {
document.querySelectorAll('[data-spoke-spinner]').forEach(function (el) {
if (el._spk) return;
el._spk = true;
var anims = build(el);
if (!anims) return;
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-spoke-spinner style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>
<!-- dots instead of bars: -->
<div data-spoke-spinner data-shape="dot" data-spokes="8"></div>