← 3D & depth

Twisting Puzzle Cube

A three-by-three puzzle cube turns slowly while its layers twist a quarter turn at a time. It scrambles itself with a run of random twists, pauses, then undoes them one by one until every face is a single colour again. Drag or flick to turn the whole cube.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-twisting-puzzle-cube to a box with a height.

Options

Add any of these attributes to the same element.

data-movesHow many twists in each scramble. Default 8.
data-durationHow long each twist takes. Default 420 ms.
data-colorsSix colours separated by commas: top, bottom, right, left, front and back. Default the classic white, yellow, red, orange, blue and green.
data-bodyColour of the plastic between the stickers. Default #1c1630.
data-speedHow fast the whole cube turns. Default 1; 0 stands it still.
data-drag="false"Don’t let people turn it by dragging.

Drawn on a canvas, paused when off screen. Each scramble is new and random, and always solved by undoing it. With reduced motion the solved cube stands still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Twisting Puzzle Cube: a three-by-three puzzle cube turns slowly while its
    // layers twist, one quarter turn at a time. It scrambles itself with a run of
    // random twists, pauses, then undoes them one by one until it's solved again.
    // Drag or flick to turn the whole cube.
    // Usage: <div data-twisting-puzzle-cube style="height: 320px"></div>
    // Optional: data-moves (twists in each scramble, default 8), data-duration
    // (ms per twist, default 420), data-colors (six colours separated by commas:
    // up, down, right, left, front, back), data-body (colour of the plastic,
    // default "#1c1630"), data-speed (how fast the cube turns, default 1),
    // data-drag="false".
    var DEFAULTS = {
      moves: 8, duration: 420, speed: 1, body: '#1c1630',
      colors: '#ffffff,#facc15,#ef4444,#f97316,#3b82f6,#22c55e'
    };

    var css =
      '[data-twisting-puzzle-cube]{position:relative;overflow:hidden;touch-action:pan-y}' +
      '.tws-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function rgb(hex) {
      hex = hex.trim().replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }
    function tint(c, k) {
      return 'rgb(' + c.map(function (v) { return Math.round(k > 1 ? v + (255 - v) * (k - 1) : v * k); }).join(',') + ')';
    }

    // Turn integer vector v a quarter turn about axis a (0 x, 1 y, 2 z), dir ±1.
    function quarter(v, a, dir) {
      var i = (a + 1) % 3, j = (a + 2) % 3, r = v.slice();
      r[i] = -dir * v[j];
      r[j] = dir * v[i];
      return r;
    }
    // The same turn by any angle, for drawing a layer mid-twist.
    function turn(v, a, ang) {
      var i = (a + 1) % 3, j = (a + 2) % 3, c = Math.cos(ang), s = Math.sin(ang), r = v.slice();
      r[i] = v[i] * c - v[j] * s;
      r[j] = v[i] * s + v[j] * c;
      return r;
    }
    function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    // The six face directions: up, down, right, left, front, back (y up, z towards us).
    var DIRS = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];

    function setup(el) {
      var d = el.dataset;
      var cols = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      while (cols.length < 6) cols.push(rgb(DEFAULTS.colors.split(',')[cols.length]));
      var body = rgb(d.body || DEFAULTS.body);
      var nMoves = Math.max(1, Math.round(+d.moves || DEFAULTS.moves));
      var dur = +d.duration || DEFAULTS.duration;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var canvas = document.createElement('canvas');
      canvas.className = 'tws-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1;

      // 27 little cubes (the hidden middle one too), each with its stickers as { n: outward direction, c: colour }.
      var cubies = [];
      for (var x = -1; x <= 1; x++) for (var y = -1; y <= 1; y++) for (var z = -1; z <= 1; z++) {
        var p = [x, y, z], st = [];
        DIRS.forEach(function (n, k) { if (p[0] * n[0] + p[1] * n[1] + p[2] * n[2] === 1) st.push({ n: n, c: cols[k] }); });
        cubies.push({ p: p, s: st });
      }

      // The twisting plan: scramble with random moves, then undo them.
      var s = { yaw: 0.65, v: 0, dragging: false, queue: [], move: null, mt: 0, wait: 600, done: [] };
      function plan() {
        var list = [], prev = -1;
        for (var i = 0; i < nMoves; i++) {
          var a;
          do { a = Math.floor(Math.random() * 3); } while (a === prev);
          prev = a;
          list.push({ a: a, l: Math.floor(Math.random() * 3) - 1, dir: Math.random() < 0.5 ? 1 : -1 });
        }
        var undo = list.slice().reverse().map(function (m) { return { a: m.a, l: m.l, dir: -m.dir, solve: true }; });
        s.queue = list.concat([{ pause: 1100 }], undo, [{ pause: 2000 }]);
      }

      function finish(m) {
        cubies.forEach(function (c) {
          if (c.p[m.a] !== m.l) return;
          c.p = quarter(c.p, m.a, m.dir);
          c.s.forEach(function (t) { t.n = quarter(t.n, m.a, m.dir); });
        });
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var r = Math.min(w * 0.15, h * 0.135);
        var ox = w / 2, oy = h / 2 - r * 0.25;
        ctx.lineJoin = 'round';
        ctx.lineWidth = Math.max(1, r / 30);
        var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw);
        var pitch = 0.5, cp = Math.cos(pitch), sp = Math.sin(pitch);
        function view(p) {
          var X = p[0] * cy + p[2] * sy, Z = -p[0] * sy + p[2] * cy;
          return [X, p[1] * cp - Z * sp, p[1] * sp + Z * cp];
        }
        function scr(p) { var k = 9 / (9 - p[2]); return [ox + p[0] * r * k, oy - p[1] * r * k]; }

        // Soft shadow below.
        ctx.save();
        ctx.translate(ox, oy + r * 2.4);
        ctx.scale(1, 0.24);
        var g = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 2.2);
        g.addColorStop(0, 'rgba(30,10,60,.3)');
        g.addColorStop(1, 'rgba(30,10,60,0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(0, 0, r * 2.2, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();

        var m = s.move, ang = m ? ease(Math.min(1, s.mt / dur)) * m.dir * Math.PI / 2 : 0;
        // Draw in three slabs across the twisting axis, far slab first: the
        // slabs never overlap, so this keeps the depth order right mid-twist.
        var axis = m ? m.a : 0;
        var ax = [0, 0, 0]; ax[axis] = 1;
        var facing = view(ax)[2];
        var slabs = facing > 0 ? [-1, 0, 1] : [1, 0, -1];
        var L = [-0.45, 0.8, 0.55];
        slabs.forEach(function (layer) {
          var items = [];
          cubies.forEach(function (c) {
            if (c.p[axis] !== layer) return;
            var moving = m && c.p[m.a] === m.l;
            var tf = function (v) { return moving ? turn(v, m.a, ang) : v; };
            var ctr = view(tf(c.p));
            items.push({ c: c, tf: tf, z: ctr[2] });
          });
          items.sort(function (a, b) { return a.z - b.z; });
          items.forEach(function (it) {
            var c = it.c;
            DIRS.forEach(function (n) {
              var nn = view(it.tf(n));
              if (nn[2] <= 0.01) return;
              var light = 0.62 + 0.38 * Math.max(0, nn[0] * L[0] + nn[1] * L[1] + nn[2] * L[2]);
              // Corners of this face of the little cube, and of its sticker.
              var i = n[0] ? 1 : 0, j = n[2] ? 1 : 2;
              function corner(u, v, half) {
                var q = [c.p[0] + n[0] * 0.48, c.p[1] + n[1] * 0.48, c.p[2] + n[2] * 0.48];
                q[i] += u * half; q[j] += v * half;
                return scr(view(it.tf(q)));
              }
              function quad(half) {
                ctx.beginPath();
                [[-1, -1], [1, -1], [1, 1], [-1, 1]].forEach(function (k, idx) {
                  var pt = corner(k[0], k[1], half);
                  if (idx) ctx.lineTo(pt[0], pt[1]); else ctx.moveTo(pt[0], pt[1]);
                });
                ctx.closePath();
              }
              ctx.fillStyle = tint(body, light);
              quad(0.5);
              ctx.fill();
              var sticker = null;
              c.s.forEach(function (t) { if (t.n[0] === n[0] && t.n[1] === n[1] && t.n[2] === n[2]) sticker = t; });
              if (!sticker) return;
              ctx.fillStyle = tint(sticker.c, light * (0.9 + 0.1 * nn[2]));
              ctx.strokeStyle = ctx.fillStyle;
              quad(0.36);
              ctx.fill();
              ctx.stroke();
            });
          });
        });
      }

      function step(dt) {
        if (s.wait > 0) { s.wait -= dt; return; }
        if (s.move) {
          s.mt += dt;
          if (s.mt >= dur) {
            finish(s.move);
            s.move = null;
            s.wait = 70;
          }
          return;
        }
        if (!s.queue.length) plan();
        var next = s.queue.shift();
        if (next.pause) { s.wait = next.pause; return; }
        s.move = next;
        s.mt = 0;
      }

      resize();
      draw();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
      if (reduced()) return;

      if (d.drag !== 'false') {
        el.style.cursor = 'grab';
        var lx = 0, lt = 0;
        el.addEventListener('pointerdown', function (e) {
          s.dragging = true;
          s.v = 0;
          lx = e.clientX; lt = performance.now();
          el.setPointerCapture(e.pointerId);
          el.style.cursor = 'grabbing';
        });
        el.addEventListener('pointermove', function (e) {
          if (!s.dragging) return;
          var now = performance.now(), dx = (e.clientX - lx) * 0.012;
          s.yaw += dx;
          s.v = dx / Math.max(8, now - lt) * 1000;
          lx = e.clientX; lt = now;
        });
        var end = function () { s.dragging = false; el.style.cursor = 'grab'; };
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
      }

      var last = 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;
          last = now;
          if (!s.dragging) {
            s.yaw += (0.3 * speed + s.v) * dt / 1000;
            s.v *= Math.pow(0.05, dt / 1000);
            if (Math.abs(s.v) < 0.02) s.v = 0;
          }
          step(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-twisting-puzzle-cube]').forEach(function (el) {
        if (el._tws) return;
        el._tws = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-twisting-puzzle-cube style="height: 320px"></div>

<!-- or in brand colours: -->
<div data-twisting-puzzle-cube
     data-colors="#fff,#fde68a,#db2777,#f472b6,#7c3aed,#a78bfa"
     style="height: 320px"></div>