Twisting puzzle cube
Pure CSSA 3×3×3 cube built as three layers that take turns twisting a quarter turn. Each layer is one flat box with the stickers painted on by gradients.
Drag to spin it, release to let it coast. JS only tracks two angles and some inertia.
Your edited version
rotateX(var(--rx)) rotateY(var(--ry)).setPointerCapture keeps the drag alive when the pointer leaves the element; touch-action: none stops the page scrolling instead.--u, so the cube is the same share of a gallery card, the editor and a recording canvas. It is sized for the worst angle a drag can reach — corner-on, where it spans its body diagonal, √3 × its side — not for the rest pose.Pointer Events + setPointerCapturerequestAnimationFrame inertiatouch-action: none<div class="scene" tabindex="0" role="img" aria-label="Wireframe cube. Drag it, or use the arrow keys, to turn it">
<div class="cube">
<div></div><div></div><div></div>
<div></div><div></div><div></div>
</div>
</div>
.scene {
display: grid;
place-items: center;
width: 100%;
height: 100%;
/* one base unit: every length below is a multiple of it, so the cube is the same share of a
card, the editor, a full screen and a recording canvas. Sized for the worst drag: turned
corner-on, the cube spans its body diagonal, √3 × 140 units */
--u: 0.26vmin;
perspective: calc(800 * var(--u));
cursor: grab;
touch-action: none; /* let JS have the drag on touch screens */
user-select: none;
}
.scene:active { cursor: grabbing; }
.cube {
--s: calc(140 * var(--u));
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
transform: rotateX(var(--rx, -24deg)) rotateY(var(--ry, 32deg));
}
.cube > * {
position: absolute;
inset: 0;
background: rgb(255 181 71 / 0.3);
border: calc(1 * var(--u)) solid rgb(255 181 71 / 0.85);
}
/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
/* the scene covers the canvas, so a ring round it would be off the canvas: focus lights the cube */
.scene:focus-visible { outline: none; }
.scene:focus-visible .cube > * { border-color: #fff; }
const scene = document.querySelector('.scene');
const cube = document.querySelector('.cube');
let rx = -24, ry = 32; // current angles
let vx = 0, vy = 0; // velocity, for inertia
let dragging = false;
let raf = 0;
function apply() {
rx = Math.max(-89, Math.min(89, rx));
cube.style.setProperty('--rx', rx + 'deg');
cube.style.setProperty('--ry', ry + 'deg');
}
function coast() {
if (dragging) return;
vx *= 0.95;
vy *= 0.95;
rx += vx;
ry += vy;
apply();
if (Math.abs(vx) + Math.abs(vy) > 0.05) raf = requestAnimationFrame(coast);
}
scene.addEventListener('pointerdown', (e) => {
dragging = true;
cancelAnimationFrame(raf);
scene.setPointerCapture(e.pointerId);
});
scene.addEventListener('pointermove', (e) => {
if (!dragging) return;
vy = e.movementX * 0.6;
vx = -e.movementY * 0.6;
rx += vx;
ry += vy;
apply();
});
function release() {
if (!dragging) return;
dragging = false;
raf = requestAnimationFrame(coast);
}
// the keyboard: each arrow key turns it 10 degrees
scene.addEventListener('keydown', (e) => {
const step = { ArrowLeft: [0, -10], ArrowRight: [0, 10], ArrowUp: [10, 0], ArrowDown: [-10, 0] }[e.key];
if (!step) return;
e.preventDefault();
cancelAnimationFrame(raf);
rx += step[0];
ry += step[1];
apply();
});
scene.addEventListener('pointerup', release);
scene.addEventListener('pointercancel', release);