← Loaders

Wandering Cubes

Two cubes start at opposite corners and travel round a square path, turning a quarter turn along every side, shrinking at one corner and growing back at the next. 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-wandering-cubes> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime for one lap. Default 1800 ms.
data-cubes1 or 2 cubes. Default 2.
data-colorsTwo comma-separated colours, one per cube. Default color, the second one faded.
data-shrinkHow small the cubes get at the in-between corners. Default 0.5.

It only moves while it’s on screen. With reduced motion the cubes sit still in their corners.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Wandering Cubes: two cubes start at opposite corners and travel round a
    // square path, a quarter turn on every side, shrinking to half size at
    // every other corner and growing back at the next. Sized with font-size,
    // coloured with color. It only moves while it's on screen.
    // Usage: <div data-wandering-cubes aria-label="Loading"></div>
    // Optional: data-speed (ms for one lap, default 1800), data-cubes (1 or 2,
    // default 2), data-colors (two comma-separated colours, one per cube),
    // data-shrink (size at the small corners, default 0.5).
    var DEFAULTS = { speed: 1800, cubes: 2, shrink: 0.5 };
    var PATH = 1.5, CUBE = 0.55; // em

    var css =
      '.wnc{position:relative;display:inline-block;width:' + (PATH + CUBE) + 'em;height:' + (PATH + CUBE) + 'em}' +
      '.wnc-c{position:absolute;left:0;top:0;width:' + CUBE + 'em;height:' + CUBE + 'em;border-radius:0.06em;background:currentColor}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // The four corners, clockwise from top left.
    var CORNERS = [[0, 0], [PATH, 0], [PATH, PATH], [0, PATH]];

    function build(el) {
      var d = el.dataset;
      var n = +d.cubes === 1 ? 1 : DEFAULTS.cubes;
      var shrink = Math.max(0.1, Math.min(1, d.shrink != null ? +d.shrink : DEFAULTS.shrink));
      var colors = d.colors ? d.colors.split(',') : [];
      el.classList.add('wnc');
      el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      var cubes = [];
      for (var i = 0; i < n; i++) {
        var c = document.createElement('span');
        c.className = 'wnc-c';
        if (colors[i]) c.style.background = colors[i].trim();
        else if (i) c.style.opacity = 0.55;
        c.setAttribute('aria-hidden', 'true');
        el.appendChild(c);
        // The second cube starts at the opposite corner.
        var start = CORNERS[i * 2];
        anime.utils.set(c, { x: start[0] + 'em', y: start[1] + 'em', rotate: 0, scale: 1 });
        cubes.push(c);
      }
      if (reduced()) return null;

      var leg = (+d.speed || DEFAULTS.speed) / 4;
      var ease = 'inOut(2)';
      return cubes.map(function (c, i) {
        var x = [], y = [], rotate = [], scale = [];
        for (var k = 1; k <= 4; k++) {
          var p = CORNERS[(i * 2 + k) % 4];
          x.push({ to: p[0] + 'em', duration: leg, ease: ease });
          y.push({ to: p[1] + 'em', duration: leg, ease: ease });
          // A quarter turn per side, anticlockwise against the clockwise path.
          rotate.push({ to: -90 * k, duration: leg, ease: ease });
          scale.push({ to: k % 2 ? shrink : 1, duration: leg, ease: ease });
        }
        return anime.animate(c, { x: x, y: y, rotate: rotate, scale: scale, loop: true, autoplay: false });
      });
    }

    function init() {
      document.querySelectorAll('[data-wandering-cubes]').forEach(function (el) {
        if (el._wnc) return;
        el._wnc = true;
        var anims = build(el);
        if (!anims) return;
        new IntersectionObserver(function (entries) {
          anims.forEach(function (a) { entries[0].isIntersecting ? a.play() : a.pause(); });
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-wandering-cubes style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>