← Loaders

Rolling Cube

A 3D cube rolls along a floor, tipping over its edge onto the next face, a few rolls one way and then back again. Its faces are lit from above, so each one darkens as it turns away from the light, and a soft shadow travels along underneath. 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-rolling-cube> where the loader should appear.

Options

Add any of these attributes to the same element.

data-rollsRolls in each direction before it turns back, 1 to 6. Default 3.
data-speedTime for each roll. Default 520 ms.
data-pauseRest between rolls. Default 120 ms.
data-floor="false"Hide the floor (the shadow stays).

The cube is 1em; the loader is one cube wider than the number of rolls, and 1.9em tall. It only moves while it’s on screen. With reduced motion the cube sits still at the start.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rolling Cube: a 3D cube rolls along a floor, tipping over its front
    // edge onto the next face, a few rolls one way and then back again. The
    // faces are lit from above, so each one darkens as it turns away from the
    // light, and a soft shadow travels with it.
    // Sized with font-size (the cube is 1em; the track is one cube longer than
    // the number of rolls), coloured with color. It only moves while it's on
    // screen.
    // Usage: <div data-rolling-cube aria-label="Loading"></div>
    // Optional: data-rolls (rolls each way, 1 to 6, default 3), data-speed
    // (ms for each roll, default 520), data-pause (ms between rolls, default
    // 120), data-floor="false" (no floor).
    var DEFAULTS = { rolls: 3, speed: 520, pause: 120 };
    var S = 1; // em, cube size

    var css =
      '.rlc{position:relative;display:inline-block;vertical-align:middle;perspective:12em;perspective-origin:50% -40%}' +
      '.rlc-world{position:absolute;left:0;right:0;bottom:.15em;height:' + S + 'em;transform-style:preserve-3d;transform:rotateX(-16deg) rotateY(-22deg)}' +
      '.rlc-cube,.rlc-floor,.rlc-shadow{position:absolute;left:0;top:0;width:' + S + 'em;height:' + S + 'em}' +
      '.rlc-cube{transform-style:preserve-3d;will-change:transform}' +
      '.rlc-face{position:absolute;inset:0;background:currentColor;box-shadow:inset 0 0 0 .035em rgba(255,255,255,.28);border-radius:.06em}' +
      '.rlc-face i{position:absolute;inset:0;border-radius:inherit;background:#000}' +
      '.rlc-floor{width:100%;left:0;background:currentColor;opacity:.09;border-radius:.1em;transform:translateY(' + S / 2 + 'em) rotateX(90deg)}' +
      '.rlc-shadow{background:radial-gradient(closest-side,rgba(0,0,0,.3),transparent);will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Each face: where it sits on the cube and which way it faces (x right,
    // y down, z towards you, as CSS has it).
    var FACES = [
      ['translateZ', [0, 0, 1]],
      ['rotateY(180deg) translateZ', [0, 0, -1]],
      ['rotateY(90deg) translateZ', [1, 0, 0]],
      ['rotateY(-90deg) translateZ', [-1, 0, 0]],
      ['rotateX(90deg) translateZ', [0, -1, 0]],
      ['rotateX(-90deg) translateZ', [0, 1, 0]],
    ];
    var L = (function () { var v = [-0.5, -1, 0.75], m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; })();

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

    function inOut(v) { return 0.5 - 0.5 * Math.cos(Math.PI * v); }

    function build(el) {
      var d = el.dataset;
      var n = Math.max(1, Math.min(6, Math.round(+d.rolls) || DEFAULTS.rolls));
      el.classList.add('rlc');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      el.style.width = ((n + 1) * S) + 'em';
      el.style.height = (S * 1.9).toFixed(2) + 'em';

      var world = document.createElement('span');
      world.className = 'rlc-world';
      world.setAttribute('aria-hidden', 'true');
      el.appendChild(world);
      if (d.floor !== 'false') {
        var floor = document.createElement('span');
        floor.className = 'rlc-floor';
        world.appendChild(floor);
      }
      var shadow = document.createElement('span');
      shadow.className = 'rlc-shadow';
      world.appendChild(shadow);
      var cube = document.createElement('span');
      cube.className = 'rlc-cube';
      world.appendChild(cube);
      var faces = FACES.map(function (f) {
        var face = document.createElement('span');
        face.className = 'rlc-face';
        face.style.transform = f[0] + '(' + S / 2 + 'em)';
        var shade = document.createElement('i');
        face.appendChild(shade);
        cube.appendChild(face);
        return { n: f[1], shade: shade };
      });

      // Light each face from its direction after turning by `a` degrees
      // round the roll axis.
      function light(a) {
        var c = Math.cos(a * Math.PI / 180), s = Math.sin(a * Math.PI / 180);
        faces.forEach(function (f) {
          var x = f.n[0] * c - f.n[1] * s, y = f.n[0] * s + f.n[1] * c, z = f.n[2];
          var b = Math.max(0, x * L[0] + y * L[1] + z * L[2]);
          f.shade.style.opacity = (0.55 * (1 - b)).toFixed(3);
        });
      }

      // Cube resting at slot `pos`, part way (v, 0 to 1) through a roll in
      // direction dir (1 right, -1 left).
      function draw(pos, v, dir) {
        var a = 90 * v * dir;
        var px = dir * S / 2, py = S / 2;     // the bottom edge it tips over
        cube.style.transform = 'translate(' + (pos * S + px).toFixed(3) + 'em,' + py + 'em) rotate(' + a.toFixed(2) + 'deg) translate(' + (-px) + 'em,' + (-py) + 'em)';
        light(a);
        // The shadow follows the cube's centre and tightens as it lifts.
        var r = a * Math.PI / 180;
        var cx = pos * S + px + (-px * Math.cos(r) + py * Math.sin(r));
        var lift = Math.abs(Math.sin(2 * r)) * 0.2;
        shadow.style.transform = 'translate(' + cx.toFixed(3) + 'em,' + (S / 2) + 'em) rotateX(90deg) scale(' + (1.25 - lift).toFixed(3) + ')';
      }

      if (reduced()) { draw(0, 0, 1); return null; }
      draw(0, 0, 1);
      var speed = Math.max(150, +d.speed || DEFAULTS.speed);
      var pause = Math.max(0, d.pause != null && !isNaN(+d.pause) ? +d.pause : DEFAULTS.pause);
      var step = speed + pause, cycle = step * n * 2;
      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;
          var c = t % cycle, k = Math.floor(c / step), v = Math.min(1, (c - k * step) / speed);
          if (k < n) draw(k, inOut(v), 1);
          else draw(2 * n - k, inOut(v), -1);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-rolling-cube]').forEach(function (el) {
        if (el._rlc) return;
        el._rlc = 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-rolling-cube style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>