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.
Pick 3 to 12 sides. JS rebuilds the panels and computes the apothem and side length with cos() and sin(); CSS places every panel from those numbers.
Your edited version
R fixed and derive the rest: side = 2R × sin(180° / n), apothem = R × cos(180° / n). JS computes both and hands them to CSS as --w and --r.rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r)). JS never touches a transform.2R square cut to the polygon. JS writes the clip-path: one corner every 360° / n, starting half a side from the centre of panel 0.n rebuilds the panels. @starting-style gives brand-new elements a first frame to transition from, so they fly in without any animation JS.JS: r = R·cos(180°/n), side = 2R·sin(180°/n)rotateY(calc(i × 1turn / n))generated clip-path polygon caps@starting-style fly-in<div class="app">
<div class="scene">
<div class="prism"></div>
</div>
<label>Sides <input type="range" min="3" max="12" value="6" /> <output>6</output></label>
</div>
.app {
display: grid;
justify-items: center;
gap: 48px;
}
.scene {
perspective: 800px;
}
.prism {
--h: 120px;
position: relative;
width: calc(var(--R) * 2);
height: var(--h);
transform-style: preserve-3d;
animation: spin 14s linear infinite;
}
/* JS sets --n, --R, --r (apothem), --w (side) and --cap; CSS only places things */
.prism i {
--hue: calc(214 - 39 * cos(var(--i) * 1turn / var(--n))); /* teal ... violet ... teal */
position: absolute;
top: 0;
left: calc(50% - var(--w) / 2);
width: var(--w);
height: 100%;
background: hsl(var(--hue) 85% 64% / 0.26);
border: 1px solid hsl(var(--hue) 85% 64% / 0.75);
box-shadow: inset 0 0 24px hsl(var(--hue) 85% 64% / 0.3);
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r));
transition: transform 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.1), opacity 0.35s;
transition-delay: calc(var(--i) * 30ms);
}
/* the first frame of a newly inserted panel: further out and invisible */
@starting-style {
.prism i {
opacity: 0;
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(calc(var(--r) + 70px));
}
}
/* caps: both rotateX(90deg), pushed up or down, so an odd polygon lines up on both ends */
.prism b {
position: absolute;
left: calc(50% - var(--R));
top: calc(50% - var(--R));
width: calc(var(--R) * 2);
height: calc(var(--R) * 2);
clip-path: var(--cap);
background: radial-gradient(circle, rgb(255 77 157 / 0.1) 15%, rgb(255 77 157 / 0.5));
transform: rotateX(90deg) translateZ(calc(var(--h) / 2));
transition: opacity 0.4s 0.25s;
}
.prism b + b {
transform: rotateX(90deg) translateZ(calc(var(--h) / -2));
}
@starting-style {
.prism b { opacity: 0; }
}
label {
display: flex;
align-items: center;
gap: 10px;
color: #949bc0;
font: 14px system-ui;
}
output {
min-width: 2ch;
color: #eceefb;
font-family: ui-monospace, monospace;
}
@keyframes spin {
from { transform: rotateX(-22deg) rotateY(0deg); }
to { transform: rotateX(-22deg) rotateY(360deg); }
}
const prism = document.querySelector('.prism');
const input = document.querySelector('input');
const output = document.querySelector('output');
const R = 80; // corner radius: the prism keeps this footprint for every n
function build() {
const n = Number(input.value);
const half = Math.PI / n; // half the angle one side spans
prism.style.setProperty('--n', n);
prism.style.setProperty('--R', R + 'px');
prism.style.setProperty('--r', R * Math.cos(half) + 'px'); // apothem
prism.style.setProperty('--w', 2 * R * Math.sin(half) + 'px'); // side length
// cap outline: a corner half a side either side of every panel's centre
const corners = [];
for (let k = 0; k < n; k++) {
const a = (2 * k + 1) * half;
corners.push((50 + 50 * Math.sin(a)) + '% ' + (50 + 50 * Math.cos(a)) + '%');
}
prism.style.setProperty('--cap', 'polygon(' + corners.join(', ') + ')');
// brand-new elements, so @starting-style flies them in
let html = '';
for (let i = 0; i < n; i++) html += '<i style="--i:' + i + '"></i>';
prism.innerHTML = html + '<b></b><b></b>';
output.textContent = n;
}
input.addEventListener('input', build);
build();
@use '../mixins' as *;
.d-shapeshift {
display: grid;
grid-template-rows: 1fr auto;
width: 100%;
height: 100%;
padding: 10px 14px;
&__view {
display: grid;
place-items: center;
perspective: 700px;
}
// JS sets --n (sides), --R (corner radius), --r (apothem = R·cos(180°/n)),
// --w (side = 2R·sin(180°/n)) and --cap (the n-gon as a clip-path polygon).
// CSS only places things from those numbers.
&__prism {
--h: 92px;
position: relative;
width: calc(var(--R) * 2);
height: var(--h);
transform-style: preserve-3d;
animation: d-shapeshift-spin 14s linear infinite;
// side panel i: turn to its own angle, then walk out by the apothem
i {
--c: color-mix(in srgb, var(--accent-2) var(--mix, 50%), var(--accent));
position: absolute;
top: 0;
left: calc(50% - var(--w) / 2);
width: var(--w);
height: 100%;
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r));
// panels are rebuilt on every change; @starting-style (below) makes new ones fly in
transition:
transform 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.1),
opacity 0.35s;
transition-delay: calc(var(--i) * 30ms);
@include face(var(--c), 26%);
@starting-style {
opacity: 0;
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(calc(var(--r) + 70px));
}
}
// caps: a 2R square cut to the n-gon, laid flat at the top and bottom. Both use rotateX(90deg)
// and only differ in the push, so the (not always symmetric) polygon lines up on both ends.
b {
position: absolute;
left: calc(50% - var(--R));
top: calc(50% - var(--R));
width: calc(var(--R) * 2);
height: calc(var(--R) * 2);
clip-path: var(--cap);
background: radial-gradient(
circle,
color-mix(in srgb, var(--hot) 10%, transparent) 15%,
color-mix(in srgb, var(--hot) 50%, transparent) 100%
);
transform: rotateX(90deg) translateZ(calc(var(--h) / 2));
transition: opacity 0.4s 0.25s;
& + b {
transform: rotateX(90deg) translateZ(calc(var(--h) / -2));
}
@starting-style {
opacity: 0;
}
}
}
&__bar {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
input {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
// wide enough for "11 · hendecagon", so the slider does not jump as the text changes
output {
min-width: 15ch;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
text-align: right;
}
}
}
@keyframes d-shapeshift-spin {
from {
transform: rotateX(-22deg) rotateY(0deg);
}
to {
transform: rotateX(-22deg) rotateY(360deg);
}
}