Stacking Blocks
Little blocks drop in one at a time and stack up, each one stretching as it falls and squashing as it lands. When the stack is finished it holds for a moment, flashes and clears away from the top down, then the first block drops again. 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-stacking-blocks>where the loader should appear.
Options
Add any of these attributes to the same element.
data-layout | pyramid (3, 2 and 1 blocks), tower (4 in a column) or wall (two rows laid like bricks). Default pyramid. |
data-speed | Time between drops. Default 420 ms. |
data-colors | Comma-separated colours: the blocks take them in turn. |
data-radius | Corner rounding, in em. Default 0.1. |
Each block is 0.8em, and there’s room above the stack for them to fall. It only moves while it’s on screen. With reduced motion the finished stack sits still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Stacking Blocks: little blocks drop in one at a time and stack up, each
// squashing a touch as it lands. When the stack is done it holds for a
// beat, flashes and clears away from the top down, then it starts again.
// Sized with font-size, coloured with color. It only moves while it's on
// screen.
// Usage: <div data-stacking-blocks aria-label="Loading"></div>
// Optional: data-layout ("pyramid", the default, 3 + 2 + 1 blocks;
// "tower", 4 in a column; or "wall", 3 + 3 laid like bricks),
// data-speed (ms between drops, default 420), data-colors (comma-separated;
// the blocks step through them), data-radius (corner rounding in em,
// default 0.1).
var DEFAULTS = { speed: 420, radius: 0.1 };
var B = 0.8, GAP = 0.08, STEP = B + GAP; // em
var LAYOUTS = {
// [column, row] from the bottom, in drop order. Columns can be halves.
pyramid: [[0, 0], [1, 0], [2, 0], [0.5, 1], [1.5, 1], [1, 2]],
tower: [[0, 0], [0, 1], [0, 2], [0, 3]],
wall: [[0, 0], [1, 0], [2, 0], [0.5, 1], [1.5, 1], [2.5, 1]],
};
var css =
'.stb{position:relative;display:inline-block;vertical-align:middle}' +
'.stb-b{position:absolute;bottom:0;left:0;width:' + B + 'em;height:' + B + 'em;background:currentColor;transform-origin:50% 100%;opacity:0;will-change:transform,opacity}' +
'.stb-b::after{content:"";position:absolute;inset:0;border-radius:inherit;background:#fff;opacity:var(--stb-flash,0)}' +
'.stb-floor{position:absolute;left:-.15em;right:-.15em;bottom:-.14em;height:.06em;border-radius:.03em;background:currentColor;opacity:.25}';
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 inQuad(v) { return v * v; }
function outBack(v) { var c = 1.7; return 1 + (c + 1) * Math.pow(v - 1, 3) + c * Math.pow(v - 1, 2); }
function build(el) {
var d = el.dataset;
var layout = LAYOUTS[d.layout] || LAYOUTS.pyramid;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
var radius = d.radius != null && !isNaN(+d.radius) ? +d.radius : DEFAULTS.radius;
var cols = 0, rows = 0;
layout.forEach(function (p) { cols = Math.max(cols, p[0] + 1); rows = Math.max(rows, p[1] + 1); });
var W = cols * STEP - GAP, H = rows * STEP - GAP;
// Leave headroom above the stack for the blocks to fall through.
var head = 1.2;
el.classList.add('stb');
if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.textContent = '';
el.style.width = W.toFixed(3) + 'em';
el.style.height = (H + head).toFixed(3) + 'em';
var floor = document.createElement('span');
floor.className = 'stb-floor';
floor.setAttribute('aria-hidden', 'true');
el.appendChild(floor);
var blocks = layout.map(function (p, i) {
var b = document.createElement('span');
b.className = 'stb-b';
b.setAttribute('aria-hidden', 'true');
b.style.borderRadius = radius + 'em';
if (colors.length) b.style.background = colors[i % colors.length];
el.appendChild(b);
var x = p[0] * STEP, y = -p[1] * STEP;
// Start just above the top of the box, whatever row it lands on.
return { el: b, x: x, y: y, from: -(H + head - B) };
});
function place(b, y, sx, sy, o, flash) {
b.el.style.transform = 'translate(' + b.x.toFixed(3) + 'em,' + y.toFixed(3) + 'em) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
b.el.style.opacity = o.toFixed(3);
b.el.style.setProperty('--stb-flash', flash.toFixed(3));
}
var n = blocks.length;
var speed = Math.max(150, +d.speed || DEFAULTS.speed);
var fall = speed * 0.85, settle = 320;
var built = (n - 1) * speed + fall + settle;
var hold = 600, flashT = 260, pop = 260, popGap = 70;
var clearAt = built + hold;
var cycle = clearAt + flashT + (n - 1) * popGap + pop + 260;
function draw(t) {
t = t % cycle;
blocks.forEach(function (b, i) {
var start = i * speed;
if (t < start) return place(b, b.from, 1, 1, 0, 0);
var u = (t - start) / fall;
if (u < 1) {
// Falling: speeding up, stretched a little, fading in at the top.
var y = b.from + (b.y - b.from) * inQuad(u);
return place(b, y, 1 - 0.08 * u, 1 + 0.12 * u, clamp01(u * 3), 0);
}
var since = t - start - fall;
var sx = 1, sy = 1;
if (since < settle) {
// Squash on landing, then spring back.
var v = since / settle;
var sq = Math.sin(v * Math.PI) * Math.exp(-v * 3);
sx = 1 + 0.22 * sq; sy = 1 - 0.28 * sq;
}
var flash = 0, o = 1, s = 1;
if (t >= clearAt) {
var f = (t - clearAt) / flashT;
flash = f < 1 ? Math.sin(f * Math.PI) * 0.7 : 0;
// Clear from the last block placed back to the first.
var pu = (t - clearAt - flashT - (n - 1 - i) * popGap) / pop;
if (pu > 0) {
pu = clamp01(pu);
s = pu < 0.35 ? 1 + 0.15 * pu / 0.35 : 1.15 * (1 - (pu - 0.35) / 0.65);
o = 1 - clamp01((pu - 0.3) / 0.7);
}
}
place(b, b.y - (1 - s) * B / 2, sx * s, sy * s, o, flash);
});
}
if (reduced()) { draw(built); return null; }
draw(0);
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(t);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-stacking-blocks]').forEach(function (el) {
if (el._stb) return;
el._stb = 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-stacking-blocks style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>
<!-- a tower in four colours: -->
<div data-stacking-blocks data-layout="tower" data-colors="#a855f7,#ec4899,#f59e0b,#10b981"></div>