← Illustrations

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

  1. Paste the snippet just before </head>.
  2. 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-colorColour of the big gear. Default #7c3aed.
data-speedSpeed 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.

1 · Snippet
<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>
2 · Your HTML
<div data-under-maintenance></div>
<h1>Back soon</h1>
<p>We&rsquo;re tightening a few bolts.</p>