← Loaders

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

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-stacking-blocks> where the loader should appear.

Options

Add any of these attributes to the same element.

data-layoutpyramid (3, 2 and 1 blocks), tower (4 in a column) or wall (two rows laid like bricks). Default pyramid.
data-speedTime between drops. Default 420 ms.
data-colorsComma-separated colours: the blocks take them in turn.
data-radiusCorner 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.

1 · Snippet
<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>
2 · Your HTML
<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>