Gears Loader
Three interlocking gears turn together. The big one drives the next, which drives the smallest, each turning the opposite way and faster the fewer teeth it has, so the teeth always stay meshed. 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-gears-loader>where the loader should appear.
Options
Add any of these attributes to the same element.
data-gears | 2 or 3 gears. Default 3. |
data-speed | Time for one turn of the big gear. Default 4000 ms. |
data-colors | Comma-separated colours, one per gear, biggest first. Default color, fading for the smaller gears. |
data-reverse | Turn the other way. |
It’s 2.4em tall. It only turns while it’s on screen. With reduced motion the gears sit still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gears Loader: two or three interlocking gears turn together, each one
// driving the next, the small ones spinning faster in the opposite
// direction so the teeth stay meshed. Sized with font-size (2.4em tall),
// coloured with color. It only turns while it's on screen.
// Usage: <div data-gears-loader aria-label="Loading"></div>
// Optional: data-gears (2 or 3, default 3), data-speed (ms for one turn of
// the big gear, default 4000), data-colors (comma-separated, one per gear,
// biggest first), data-reverse (turn the other way).
var DEFAULTS = { gears: 3, speed: 4000 };
var css =
'.grl{display:inline-block;height:2.4em;vertical-align:middle}' +
'.grl svg{display:block;width:100%;height:100%;overflow:visible}' +
'.grl path{fill:currentColor;fill-rule:evenodd}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
var M = 2; // tooth size (module): every gear shares it, so they mesh
var DEG = Math.PI / 180;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A gear outline centred on 0,0 with a hole in the middle.
function gearPath(n) {
var r = n * M / 2, ro = r + M * 0.9, ri = r - M * 1.05, p = 360 / n, pts = [];
function pt(a, rad) { pts.push((Math.cos(a * DEG) * rad).toFixed(2) + ',' + (Math.sin(a * DEG) * rad).toFixed(2)); }
for (var k = 0; k < n; k++) {
var c = k * p;
pt(c - p * 0.5, ri); pt(c - p * 0.28, ri);
pt(c - p * 0.16, ro); pt(c + p * 0.16, ro);
pt(c + p * 0.28, ri);
}
var h = Math.max(1.2, r * 0.32);
return 'M' + pts.join('L') + 'Z' +
'M' + h + ',0A' + h + ',' + h + ' 0 1 0 ' + -h + ',0A' + h + ',' + h + ' 0 1 0 ' + h + ',0Z';
}
function frac(x) { return x - Math.floor(x); }
// Place `child` (n teeth) touching `parent` in direction `angle`, with its
// teeth set into the parent's gaps.
function mesh(parent, n, angle) {
var r = n * M / 2, dist = parent.r + r;
var child = {
n: n, r: r,
x: parent.x + Math.cos(angle * DEG) * dist,
y: parent.y + Math.sin(angle * DEG) * dist,
ratio: -parent.ratio * parent.n / n,
};
var u = frac((angle - parent.base) / (360 / parent.n));
child.base = angle + 180 - frac(0.5 - u) * (360 / n);
return child;
}
function build(el) {
var d = el.dataset;
var count = +d.gears === 2 ? 2 : DEFAULTS.gears;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
var gears = [{ n: 16, r: 16, x: 0, y: 0, ratio: 1, base: 0 }];
gears.push(mesh(gears[0], 10, -38));
if (count === 3) gears.push(mesh(gears[1], 7, 40));
// Fit the viewBox round the gears.
var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
gears.forEach(function (g) {
var ro = g.r + M * 0.9;
x0 = Math.min(x0, g.x - ro); y0 = Math.min(y0, g.y - ro);
x1 = Math.max(x1, g.x + ro); y1 = Math.max(y1, g.y + ro);
});
var pad = 0.5;
var vw = x1 - x0 + pad * 2, vh = y1 - y0 + pad * 2;
el.classList.add('grl');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
el.style.width = (2.4 * vw / vh).toFixed(3) + 'em';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', [x0 - pad, y0 - pad, vw, vh].map(function (v) { return v.toFixed(2); }).join(' '));
svg.setAttribute('aria-hidden', 'true');
gears.forEach(function (g, i) {
g.el = document.createElementNS(NS, 'path');
g.el.setAttribute('d', gearPath(g.n));
if (colors[i]) g.el.style.fill = colors[i];
else if (i) g.el.style.opacity = i === 1 ? 0.7 : 0.45;
svg.appendChild(g.el);
});
el.appendChild(svg);
var dir = d.reverse !== undefined && d.reverse !== 'false' ? -1 : 1;
function draw(a) {
gears.forEach(function (g) {
g.el.setAttribute('transform', 'translate(' + g.x.toFixed(2) + ' ' + g.y.toFixed(2) + ') rotate(' + (g.base + a * g.ratio).toFixed(2) + ')');
});
}
draw(0);
if (reduced()) return null;
var speed = Math.max(500, +d.speed || DEFAULTS.speed);
var t = 0, last = 0;
return anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += last ? Math.min(50, now - last) : 16;
last = now;
draw(dir * 360 * t / speed);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-gears-loader]').forEach(function (el) {
if (el._grl) return;
el._grl = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-gears-loader style="font-size: 24px; color: #6a0dad" aria-label="Working on it"></div>