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.Pointer Events + setPointerCapturerequestAnimationFrame inertiatouch-action: none<div class="scene">
<div class="cube">
<div></div><div></div><div></div>
<div></div><div></div><div></div>
</div>
</div>
.scene {
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
perspective: 800px;
cursor: grab;
touch-action: none; /* let JS have the drag on touch screens */
user-select: none;
}
.scene:active { cursor: grabbing; }
.cube {
--s: 140px;
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: 1px 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)); }
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);
}
scene.addEventListener('pointerup', release);
scene.addEventListener('pointercancel', release);
@use '../mixins' as *;
.d-drag {
display: grid;
place-items: center;
width: 100%;
height: 100%;
transform-style: preserve-3d;
cursor: grab;
user-select: none;
&:active {
cursor: grabbing;
}
&__cube {
--s: 100px;
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
// The only thing JS touches: two angles.
transform: rotateX(var(--rx, -24deg)) rotateY(var(--ry, 32deg));
@include cube-faces(var(--s));
i {
position: absolute;
inset: 0;
@include face(var(--warm), 30%);
background-image:
linear-gradient(rgb(255 255 255 / 0.25) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.25) 1px, transparent 1px);
background-size: 25% 25%;
}
}
small {
position: absolute;
bottom: 10px;
color: var(--muted);
font-size: 12px;
}
}