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
- Paste the snippet just before
</head>. - Add
data-twisting-puzzle-cubeto a box with a height.
Options
Add any of these attributes to the same element.
data-moves | How many twists in each scramble. Default 8. |
data-duration | How long each twist takes. Default 420 ms. |
data-colors | Six colours separated by commas: top, bottom, right, left, front and back. Default the classic white, yellow, red, orange, blue and green. |
data-body | Colour of the plastic between the stickers. Default #1c1630. |
data-speed | How 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.
<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><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>