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 — plain numbers, never lengths: CSS multiplies them by --u, the one base unit every length here is a multiple of, so the prism is the same share of a gallery card, the editor and a recording canvas.rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--apothem)), where --apothem is calc(var(--r) * var(--u)). 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 grow out from the axis without any animation JS. It is scoped to a .grow class that JS adds only once the slider moves, so the prism the page opens on is whole from its very first frame, and a paused card shows it full-sized.vmin, so it is the same share of a card and a full screen. build() also writes --pos, how far along the slider is from 0 to 1, which is where its filled track ends.JS: r = R·cos(180°/n), side = 2R·sin(180°/n)rotateY(calc(i × 1turn / n))generated clip-path polygon caps@starting-style grow-in from the axis<div class="band">
<div class="view">
<div class="scene">
<div class="prism"></div>
</div>
</div>
<div class="controls">
<output class="caption">6 sides</output>
<div class="row">
<label>Sides <input type="range" min="3" max="12" value="6" aria-label="Number of sides" /></label>
</div>
</div>
</div>
/* the prism and the control zone stand in one stack, so the zone is the same distance below
the model in every model */
.band {
/* one base unit: every length in the prism is a multiple of it, so it is the same share of a
card, the editor, a full screen and a recording canvas. The control zone is in plain vmin,
because it is the same object in every model. */
--u: 0.26vmin;
display: grid;
justify-items: center;
gap: 4vmin;
}
/* the model box */
.view {
display: grid;
place-items: center;
height: 50vmin;
}
.scene {
perspective: calc(800 * var(--u));
}
.prism {
--h: calc(120 * var(--u));
position: relative;
/* JS writes --R, --r and --w as plain numbers; they become lengths here, in the model's unit */
--radius: calc(var(--R) * var(--u));
--apothem: calc(var(--r) * var(--u));
--side: calc(var(--w) * var(--u));
width: calc(var(--radius) * 2);
height: var(--h);
transform-style: preserve-3d;
animation: spin 14s linear infinite;
}
/* JS sets --n, --R (radius), --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(--side) / 2);
/* one side wide, border included, so neighbouring faces meet edge to edge */
box-sizing: border-box;
width: var(--side);
height: 100%;
background: hsl(var(--hue) 85% 64% / 0.26);
border: calc(1 * var(--u)) solid hsl(var(--hue) 85% 64% / 0.75);
box-shadow: inset 0 0 calc(24 * var(--u)) hsl(var(--hue) 85% 64% / 0.3);
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--apothem));
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: on the axis and invisible, so it grows out into place
and never reaches past the prism's own footprint on the way. Only once the slider has moved
(.grow): the prism the page opens on starts whole, so its first frame, the one a paused card
holds, is the full-sized shape */
@starting-style {
.prism.grow i {
opacity: 0;
transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(0);
}
}
/* 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(--radius));
top: calc(50% - var(--radius));
width: calc(var(--radius) * 2);
height: calc(var(--radius) * 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.grow b { opacity: 0; }
}
/* the control zone: the same object, at the same size, in every model that has one */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* a slider, inside its pill: track and thumb in vmin, never the browser's own px, so it is the
same share of every canvas. The empty track is the unselected tint and the filled part the
selected gradient; the thumb is the stage's ink in a gradient ring, because the gradient alone
is under 3:1 on the pill's tint on the dark stage. Chrome has no filled part of its own: the
model's JS writes --pos (0 to 1) on the input as it moves, and the fill ends under the thumb */
.controls input[type='range'] {
--thumb: 4.5vmin;
appearance: none;
width: 28vmin;
height: var(--thumb);
margin: 0; /* the browser's is 2px */
background: none;
color: inherit; /* the thumb is the stage's ink: a form control does not inherit it by itself */
cursor: pointer;
}
.controls input[type='range']::-webkit-slider-runnable-track {
height: 1.2vmin;
border-radius: 999px;
background:
linear-gradient(90deg, #6a45f5, #d1206f) 0 0 / calc(var(--thumb) / 2 + var(--pos, 0) * (100% - var(--thumb))) 100% no-repeat,
rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: var(--thumb);
height: var(--thumb);
margin-top: calc((1.2vmin - var(--thumb)) / 2);
border: 0.8vmin solid transparent;
border-radius: 50%;
background:
linear-gradient(currentColor, currentColor) padding-box,
linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
.controls input[type='range']::-moz-range-track {
height: 1.2vmin;
border-radius: 999px;
background: rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-moz-range-progress {
height: 1.2vmin;
border-radius: 999px;
background: linear-gradient(90deg, #6a45f5, #d1206f);
}
.controls input[type='range']::-moz-range-thumb {
box-sizing: border-box;
width: var(--thumb);
height: var(--thumb);
border: 0.8vmin solid transparent;
border-radius: 50%;
background:
linear-gradient(currentColor, currentColor) padding-box,
linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
/* focused from the keyboard, the ring goes round the whole pill, out on the stage like every
other control's: round the thumb it would sit on the tint, where #6a45f5 is under 3:1 */
.controls input[type='range']:focus-visible {
outline: 0;
}
.controls label:has(input[type='range']:focus-visible) {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
.controls label {
display: flex;
align-items: center;
gap: 2vmin;
}
@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, in the model's units: 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);
// plain numbers: CSS multiplies them by --u, so the prism scales with the canvas
prism.style.setProperty('--R', R);
prism.style.setProperty('--r', R * Math.cos(half)); // apothem
prism.style.setProperty('--w', 2 * R * Math.sin(half)); // 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 + ' sides';
// how far along the slider is, 0 to 1: the filled part of its track
input.style.setProperty('--pos', (n - input.min) / (input.max - input.min));
}
// the first build stands whole; every rebuild after it flies its new panels in
input.addEventListener('input', () => {
prism.classList.add('grow');
build();
});
build();