← Illustrations

Under Construction

A tower crane builds a little stack of blocks. The trolley runs out along the jib, the hook lowers each block into place, swinging a little on the way, lets go and heads back for the next. When the stack is done it clears away and starts again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-under-construction> where the scene should go. It fills the element with an SVG scene.

Options

Add any of these attributes to the same element.

data-colorsComma-separated block colours, used in turn. Default violet, amber, pink and lilac.
data-craneColour of the crane. Default #f59e0b.
data-speedSpeed multiplier. Default 1.

It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion it shows the stack nearly finished.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Under Construction: a tower crane builds a little stack of blocks. The
    // trolley runs out along the jib, the hook lowers each block into place
    // (it swings a little on the way), lets go and goes back for the next.
    // When the stack is done it clears away and starts again. Drawn as SVG
    // that fills the element, and paused when off screen.
    // Usage: <div data-under-construction></div>
    // It is 8:5 by default on a transparent background.
    // Optional: data-colors (comma-separated block colours, default violet,
    // amber and pink), data-crane (crane colour, default #f59e0b),
    // data-speed (default 1).
    var DEFAULTS = { colors: ['#7c3aed', '#f59e0b', '#ec4899', '#a78bfa'], crane: '#f59e0b', speed: 1 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '[data-under-construction]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.ucn-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; }
    function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
    function inOut(k) { k = clamp01(k); return k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; }

    var GROUND = 226, JIB = 46, BW = 36, BH = 24, PICK = 128;
    // A small pyramid of six blocks: centre x, top y.
    var SLOTS = [
      [236, GROUND - BH], [274, GROUND - BH], [312, GROUND - BH],
      [255, GROUND - 2 * BH], [293, GROUND - 2 * BH],
      [274, GROUND - 3 * BH]
    ];

    function lattice(x1, y1, x2, y2, n, vertical) {
      var d = '';
      for (var i = 0; i < n; i++) {
        var a = i / n, b = (i + 1) / n;
        if (vertical) d += 'M' + x1 + ' ' + (y1 + (y2 - y1) * a) + 'L' + x2 + ' ' + (y1 + (y2 - y1) * b) + 'M' + x2 + ' ' + (y1 + (y2 - y1) * b) + 'L' + x1 + ' ' + (y1 + (y2 - y1) * b);
        else d += 'M' + (x1 + (x2 - x1) * a) + ' ' + y1 + 'L' + (x1 + (x2 - x1) * b) + ' ' + y2 + 'M' + (x1 + (x2 - x1) * b) + ' ' + y2 + 'L' + (x1 + (x2 - x1) * b) + ' ' + y1;
      }
      return d;
    }

    function build(el) {
      var d = el.dataset;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var crane = d.crane || DEFAULTS.crane;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A crane stacking blocks: under construction');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      el.innerHTML =
        '<svg class="ucn-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<path d="M-800 ' + GROUND + 'H1200" stroke="#1e1b2e" stroke-width="3" opacity=".25"/>' +
        // Mast, cab, jib, counter-jib, ties.
        '<g fill="none" stroke="' + crane + '" stroke-width="2.4" stroke-linejoin="round">' +
          '<path d="M70 ' + GROUND + 'V' + JIB + 'M84 ' + GROUND + 'V' + JIB + '"/>' +
          '<path d="' + lattice(70, JIB, 84, GROUND, 9, true) + '" stroke-width="1.6"/>' +
          '<path d="M30 ' + JIB + 'H360M30 ' + (JIB + 10) + 'H360M360 ' + JIB + 'V' + (JIB + 10) + '"/>' +
          '<path d="' + lattice(84, JIB, 360, JIB + 10, 14, false) + '" stroke-width="1.4"/>' +
          '<path d="M77 ' + (JIB - 30) + 'L70 ' + JIB + 'M77 ' + (JIB - 30) + 'L84 ' + JIB + '"/>' +
          '<path d="M77 ' + (JIB - 30) + 'L300 ' + JIB + 'M77 ' + (JIB - 30) + 'L36 ' + JIB + '" stroke-width="1.2"/>' +
        '</g>' +
        '<rect x="30" y="' + (JIB + 10) + '" width="26" height="18" rx="2" fill="#475569"/>' +
        '<rect x="84" y="' + (JIB + 10) + '" width="20" height="16" rx="3" fill="#334155"/><rect x="92" y="' + (JIB + 13) + '" width="9" height="7" rx="1.5" fill="#bae6fd"/>' +
        '<rect x="62" y="' + (GROUND - 6) + '" width="30" height="6" rx="1" fill="#475569"/>' +
        // A traffic cone, for the look of it.
        '<path d="M352 ' + GROUND + 'L360 ' + (GROUND - 24) + 'H364L372 ' + GROUND + 'Z" fill="#f97316"/><path d="M356.5 ' + (GROUND - 12) + 'H367.5L366 ' + (GROUND - 17) + 'H358Z" fill="#fff"/>' +
        '<rect x="348" y="' + (GROUND - 3) + '" width="28" height="3" rx="1" fill="#ea580c"/>' +
        '<g class="ucn-placed"></g>' +
        '<line class="ucn-cable" stroke="#334155" stroke-width="1.6"/>' +
        '<g class="ucn-load"><rect class="ucn-block" x="' + (-BW / 2) + '" y="10" width="' + BW + '" height="' + BH + '" rx="3"/>' +
          '<path d="M0 0L-13 10M0 0L13 10" stroke="#334155" stroke-width="1.2" fill="none" class="ucn-sling"/>' +
          '<path d="M0 -6V0M-3 -2A3 3 0 0 0 3 -2" stroke="#334155" stroke-width="2" fill="none" stroke-linecap="round"/></g>' +
        '<g class="ucn-trolley"><rect x="-9" y="' + (JIB + 8) + '" width="18" height="7" rx="2" fill="#334155"/></g>' +
        '<g class="ucn-dust" opacity="0"><circle cx="-14" r="4" fill="#cbd5e1"/><circle cx="14" r="4" fill="#cbd5e1"/><circle cx="-24" r="3" fill="#e2e8f0"/><circle cx="24" r="3" fill="#e2e8f0"/></g>' +
        '</svg>';

      var svg = el.firstChild;
      var placedG = svg.querySelector('.ucn-placed');
      var cable = svg.querySelector('.ucn-cable');
      var load = svg.querySelector('.ucn-load');
      var block = svg.querySelector('.ucn-block');
      var sling = svg.querySelector('.ucn-sling');
      var trolley = svg.querySelector('.ucn-trolley');
      var dust = svg.querySelector('.ucn-dust');
      var placed = [];

      function addBlock(i, x, y) {
        var r = document.createElementNS(NS, 'rect');
        r.setAttribute('x', x - BW / 2); r.setAttribute('y', y);
        r.setAttribute('width', BW); r.setAttribute('height', BH); r.setAttribute('rx', 3);
        r.setAttribute('fill', colors[i % colors.length]);
        placedG.appendChild(r);
        var l = document.createElementNS(NS, 'rect');
        l.setAttribute('x', x - BW / 2 + 4); l.setAttribute('y', y + 4);
        l.setAttribute('width', BW - 8); l.setAttribute('height', 3); l.setAttribute('rx', 1.5);
        l.setAttribute('fill', '#fff'); l.setAttribute('opacity', '.3');
        placedG.appendChild(l);
        placed.push(r, l);
      }

      // One block's trip, in ms: hang, run out, lower, let go, rise, run back.
      var TRIP = 3400;
      var state = { i: 0, t: 0, swing: 0, swingV: 0, vel: 0, lastX: PICK, clearing: 0, dust: 0 };

      function trip(i, t) {
        var slot = SLOTS[i], hang = 46, low = slot[1] - 10 - JIB - 15;
        var tx = PICK, len = hang, carrying = true, settle = false, k;
        if (t < 300) { len = hang; }
        else if (t < 1300) { k = inOut((t - 300) / 1000); tx = PICK + (slot[0] - PICK) * k; }
        else if (t < 2100) { tx = slot[0]; settle = true; len = hang + (low - hang) * inOut((t - 1300) / 800); }
        else if (t < 2300) { tx = slot[0]; len = low; carrying = false; }
        else if (t < 2800) { tx = slot[0]; len = low + (hang - low) * inOut((t - 2300) / 500); carrying = false; }
        else { k = inOut((t - 2800) / 600); tx = slot[0] + (PICK - slot[0]) * k; carrying = false; }
        return { tx: tx, len: len, carrying: carrying, settle: settle };
      }

      function render(s, dt) {
        var tx = s.tx, len = s.len;
        // A pendulum, nudged by the trolley's acceleration and damped.
        var v = dt ? (tx - state.lastX) / dt : 0;
        var acc = dt ? (v - state.vel) / dt : 0;
        state.vel = v; state.lastX = tx;
        if (dt) {
          var L = Math.max(30, len), w = 0.000015 * (60 / L), damp = s.settle ? 0.008 : 0.0015;
          state.swingV += (-w * state.swing - (acc / L) * 0.2 - state.swingV * damp) * dt;
          state.swing += state.swingV * dt;
          state.swing = Math.max(-0.3, Math.min(0.3, state.swing));
        }
        var hx = tx + Math.sin(state.swing) * len, hy = JIB + 15 + Math.cos(state.swing) * len;
        trolley.setAttribute('transform', 'translate(' + tx.toFixed(2) + ' 0)');
        cable.setAttribute('x1', tx.toFixed(2)); cable.setAttribute('y1', JIB + 15);
        cable.setAttribute('x2', hx.toFixed(2)); cable.setAttribute('y2', hy.toFixed(2));
        load.setAttribute('transform', 'translate(' + hx.toFixed(2) + ' ' + hy.toFixed(2) + ') rotate(' + (-state.swing * 57.3 * 0.5).toFixed(2) + ')');
        block.style.display = sling.style.display = s.carrying ? '' : 'none';
      }

      function step(dt) {
        if (state.clearing) {
          // The finished stack sinks away and fades, then the next build starts.
          state.clearing += dt;
          var k = clamp01(state.clearing / 900);
          placedG.setAttribute('opacity', (1 - k).toFixed(3));
          placedG.setAttribute('transform', 'translate(0 ' + (k * 10).toFixed(2) + ')');
          render({ tx: PICK, len: 46, carrying: k > 0.6 }, dt);
          block.setAttribute('opacity', clamp01((k - 0.6) / 0.4).toFixed(3));
          if (state.clearing > 1100) {
            state.clearing = 0;
            placed.forEach(function (n) { n.remove(); });
            placed = [];
            placedG.setAttribute('opacity', 1);
            placedG.removeAttribute('transform');
            block.setAttribute('opacity', 1);
          }
          return;
        }
        var before = state.t;
        state.t += dt;
        block.setAttribute('fill', colors[state.i % colors.length]);
        if (before < 2100 && state.t >= 2100) {
          addBlock(state.i, SLOTS[state.i][0], SLOTS[state.i][1]);
          dust.setAttribute('transform', 'translate(' + SLOTS[state.i][0] + ' ' + (SLOTS[state.i][1] + BH - 2) + ')');
          state.dust = 1;
        }
        if (state.dust) {
          state.dust = Math.max(0, state.dust - dt / 500);
          dust.setAttribute('opacity', (state.dust * 0.9).toFixed(3));
          var s = 1 + (1 - state.dust) * 0.8;
          dust.setAttribute('transform', 'translate(' + SLOTS[state.i][0] + ' ' + (SLOTS[state.i][1] + BH - 2) + ') scale(' + s.toFixed(3) + ' 1)');
        }
        if (state.t >= TRIP) {
          state.t = 0;
          state.i++;
          if (state.i >= SLOTS.length) { state.i = 0; state.clearing = 1; }
        }
        render(trip(state.i, state.t), dt);
      }

      if (reduced()) {
        // The stack nearly done, the last block on its way down.
        for (var i = 0; i < SLOTS.length - 1; i++) addBlock(i, SLOTS[i][0], SLOTS[i][1]);
        state.i = SLOTS.length - 1;
        block.setAttribute('fill', colors[state.i % colors.length]);
        render(trip(state.i, 1700), 0);
        return;
      }

      var last = 0, holdT = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = (last ? Math.min(50, now - last) : 16) * speed;
          last = now;
          // Admire the finished stack for a moment before clearing it.
          if (state.clearing === 1 && holdT < 1400) { holdT += dt; render({ tx: PICK, len: 46, carrying: false }, dt); return; }
          if (state.clearing > 1) holdT = 0;
          step(dt);
        }
      });
      render(trip(0, 0), 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-construction]').forEach(function (el) {
        if (el._ucn) return;   // idempotent
        el._ucn = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-under-construction></div>
<h1>Coming soon</h1>

<!-- in your own colours: -->
<div data-under-construction data-colors="#0ea5e9,#22c55e" data-crane="#facc15"></div>