Under Maintenance
Three meshed gears turn together, each at the right speed for its size, while a spanner taps away at the big one with a little flash on every knock. For maintenance and “back soon” pages.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-under-maintenance>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-color | Colour of the big gear. Default #7c3aed. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion it’s a still picture.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Under Maintenance: three meshed gears turn together, each at the right
// speed for its size, while a spanner taps away at the big one with a
// little flash on every knock. For maintenance and "back soon" pages.
// Drawn as SVG that fills the element, and paused when off screen.
// Usage: <div data-under-maintenance></div>
// It is 8:5 by default on a transparent background.
// Optional: data-color (main gear colour, default #7c3aed), data-speed
// (default 1).
var DEFAULTS = { color: '#7c3aed', speed: 1 };
var M = 4.4; // pitch radius per tooth, so gears of any size mesh
var css =
'[data-under-maintenance]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
'.umt-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A gear around (0, 0) with n teeth, a tooth pointing along +x, and a hole.
function gear(n, hole) {
var rp = n * M, ro = rp + 5, ri = rp - 5.5, d = '', step = Math.PI * 2 / n;
for (var i = 0; i < n; i++) {
var a = i * step;
var pts = [[ri, a - step * 0.5], [ri, a - step * 0.27], [ro, a - step * 0.15], [ro, a + step * 0.15], [ri, a + step * 0.27]];
pts.forEach(function (p, j) {
d += (i === 0 && j === 0 ? 'M' : 'L') + (p[0] * Math.cos(p[1])).toFixed(2) + ' ' + (p[0] * Math.sin(p[1])).toFixed(2);
});
}
d += 'Z';
// The hole, wound the other way so it cuts out.
d += 'M' + hole + ' 0A' + hole + ' ' + hole + ' 0 1 0 ' + (-hole) + ' 0A' + hole + ' ' + hole + ' 0 1 0 ' + hole + ' 0Z';
return d;
}
function build(el) {
var d = el.dataset;
var color = d.color || DEFAULTS.color;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Gears turning while a spanner works on them: under maintenance');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
// Big gear, then each next one meshed on at an angle to the one before.
var gears = [{ n: 14, x: 168, y: 150, col: color, hole: 14 }];
// The spanner pivots at the end of its handle, up and to the left, and
// its head (98 from the pivot) meets the big gear's rim at angle HIT.
var HIT = 40, reach = 98 + 14 * M + 5;
var PIVOT = [Math.round(168 - Math.cos(HIT * Math.PI / 180) * reach), Math.round(150 - Math.sin(HIT * Math.PI / 180) * reach)];
var specs = [{ n: 9, ang: -28, col: '#c4b5fd', hole: 9 }, { n: 7, ang: 48, col: '#1e1b2e', hole: 7 }];
specs.forEach(function (s, i) {
var prev = gears[i], a = s.ang * Math.PI / 180, dist = (prev.n + s.n) * M;
gears.push({ n: s.n, x: prev.x + Math.cos(a) * dist, y: prev.y + Math.sin(a) * dist, col: s.col, hole: s.hole, ang: s.ang });
});
// Phases so the teeth interlock: each gear's gap faces the previous
// gear's tooth along the line between their centres.
gears[0].phase = 0;
for (var i = 1; i < gears.length; i++) {
var p = gears[i - 1], g = gears[i];
// As the previous gear turns by x, this one turns by -x * p.n / g.n.
g.phase = 180 + g.ang + 180 / g.n - (p.phase - g.ang) * (p.n / g.n);
}
var html = '<svg class="umt-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<ellipse cx="200" cy="236" rx="150" ry="7" fill="#1e1b2e" opacity=".07"/>';
gears.forEach(function (g, i) {
html += '<g transform="translate(' + g.x.toFixed(2) + ' ' + g.y.toFixed(2) + ')"><g class="umt-gear umt-g' + i + '">' +
'<path d="' + gear(g.n, g.hole) + '" fill="' + g.col + '" fill-rule="evenodd"/>' +
'<circle r="' + (g.n * M - 13).toFixed(1) + '" fill="none" stroke="#fff" stroke-width="2" opacity=".18"/>' +
'</g></g>';
});
html +=
'<g transform="translate(' + PIVOT[0] + ' ' + PIVOT[1] + ')"><g class="umt-spanner">' +
'<rect x="0" y="-6" width="74" height="12" rx="6" fill="#64748b"/>' +
'<rect x="9" y="-2" width="50" height="4" rx="2" fill="#94a3b8"/>' +
'<path d="M88 -6L98.83 -6A16 16 0 1 0 98.83 6L88 6Z" fill="#64748b"/>' +
'</g></g>' +
'<g class="umt-knock" opacity="0">' +
'<path d="M0 -10V-20M8 -6L15 -13M-8 -6L-15 -13" stroke="#f59e0b" stroke-width="3" stroke-linecap="round"/>' +
'</g>' +
'</svg>';
el.innerHTML = html;
var svg = el.firstChild;
var gearEls = gears.map(function (g, i) { return svg.querySelector('.umt-g' + i); });
var spanner = svg.querySelector('.umt-spanner');
var knock = svg.querySelector('.umt-knock');
// Where the head knocks the big gear's rim, flashing outwards.
var hx = PIVOT[0] + Math.cos(HIT * Math.PI / 180) * 98, hy = PIVOT[1] + Math.sin(HIT * Math.PI / 180) * 98;
knock.setAttribute('transform', 'translate(' + hx.toFixed(1) + ' ' + hy.toFixed(1) + ') rotate(' + (HIT - 90).toFixed(1) + ')');
function frame(t) {
var turn = t * 0.024; // degrees the big gear has turned
gears.forEach(function (g, i) {
var a = i === 0 ? g.phase + turn : g.phase - (gears[i - 1].rot - gears[i - 1].phase) * (gears[i - 1].n / g.n);
g.rot = a;
gearEls[i].setAttribute('transform', 'rotate(' + a.toFixed(2) + ')');
});
// Tap, tap, tap: lift slowly, drop fast, a small bounce.
var k = (t % 760) / 760, ang;
if (k < 0.55) ang = HIT - 22 * Math.sin((k / 0.55) * Math.PI / 2);
else if (k < 0.7) ang = HIT - 22 * Math.cos(((k - 0.55) / 0.15) * Math.PI / 2);
else ang = HIT - 4 * Math.sin(((k - 0.7) / 0.3) * Math.PI);
spanner.setAttribute('transform', 'rotate(' + ang.toFixed(2) + ')');
var flash = k >= 0.7 && k < 0.86 ? 1 - (k - 0.7) / 0.16 : 0;
knock.setAttribute('opacity', flash.toFixed(3));
}
if (reduced()) {
frame(0);
knock.setAttribute('opacity', 0);
return;
}
var t = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) * speed;
last = now;
frame(t);
}
});
frame(0);
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-under-maintenance]').forEach(function (el) {
if (el._umt) return; // idempotent
el._umt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-under-maintenance></div>
<h1>Back soon</h1>
<p>We’re tightening a few bolts.</p>