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
- Paste the snippet just before
</head>. - 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-colors | Comma-separated block colours, used in turn. Default violet, amber, pink and lilac. |
data-crane | Colour of the crane. Default #f59e0b. |
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 shows the stack nearly finished.
<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><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>