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.
Scroll inside the box. JS turns scroll progress into one number; CSS maps it to rotation.
Your edited version
--p, from 0 to 1.rotateY(calc(var(--p) * 720deg)). Change the feel by editing CSS only..scroller covers it (position: absolute; inset: 0) with overflow-y: auto, so a wheel or a swipe anywhere over the canvas scrolls it, and its native scrollbar is hidden. A spacer three canvases tall gives it something to scroll, and position: sticky keeps the cube in view while it passes.Scroll to spin, since nothing else shows that the canvas scrolls. It fades out over the first eighth of the scroll, and the cube, which sat a little high to make room for it, eases down to the middle as it goes. Both follow --hint, worked out in CSS from --p, so scrolling back to the top brings it back.animation-timeline: scroll() — check browser support before relying on it; the JS version works everywhere.--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 the scroll turns it to — corner-on, where it spans its body diagonal — not for the rest pose.scroll progress 0…1 in --pposition: sticky stageCSS-only alternative: animation-timeline: scroll()<div class="scroller" tabindex="0" aria-label="Scroll to spin the cube">
<div class="sticky">
<div class="cube">
<div></div><div></div><div></div>
<div></div><div></div><div></div>
</div>
<p class="caption">Scroll to spin</p>
</div>
<div class="spacer"></div>
</div>
/* the canvas is the scroll container: this box covers it, so a wheel anywhere over the canvas
scrolls it */
.scroller {
/* 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 */
--u: 0.27vmin;
/* the hint: 1 at the top, gone an eighth of the way down (--p is set here from JS) */
--hint: clamp(0, 1 - var(--p, 0) * 8, 1);
position: absolute;
inset: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
outline: none;
scrollbar-width: none; /* a bar against the canvas edge reads as a broken page: the caption says it */
}
.scroller::-webkit-scrollbar {
display: none;
}
.sticky {
position: sticky;
top: 0;
display: grid;
place-items: center;
height: 100%;
perspective: calc(700 * var(--u));
}
.spacer {
height: 300%;
}
/* the caption, in plain vmin like every model's: 4.5vmin text on a 5.4vmin line, 4vmin under the
cube at rest (45vmin tall). The cube and the caption stand in one stack, centred: the cube sits
4.7vmin high, half the gap and the line, and comes down to the middle as the caption fades */
.caption {
position: absolute;
inset: calc(50% + 21.8vmin) 0 auto;
margin: 0;
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
text-align: center;
opacity: calc(0.7 * var(--hint));
pointer-events: none;
}
.cube {
--s: calc(130 * var(--u));
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
translate: 0 calc(-4.7vmin * var(--hint));
/* --p is scroll progress 0…1, set on .scroller from JS */
transform:
rotateX(calc(-20deg + var(--p, 0) * 360deg))
rotateY(calc(var(--p, 0) * 720deg));
}
.cube > * {
position: absolute;
inset: 0;
background: rgb(255 181 71 / 0.32);
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 scroller covers the canvas, so a ring round it would be off the canvas: focus lights the
cube instead, and the arrow keys and Page Up / Down scroll it as usual */
.scroller:focus-visible .cube > * {
background: rgb(255 181 71 / 0.5);
border-color: #fff;
}
const box = document.querySelector('.scroller');
function onScroll() {
const max = box.scrollHeight - box.clientHeight;
box.style.setProperty('--p', max > 0 ? box.scrollTop / max : 0);
}
box.addEventListener('scroll', onScroll, { passive: true });
onScroll();