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.
JS picks a random face and adds full turns; a CSS transition does the tumbling.
Your edited version
+720°) on every roll, so it always tumbles instead of taking a shortcut.transition with an overshoot easing is the roll animation.--u, tied to the canvas, so it is the same share of a gallery card, the editor and a recording canvas. The Roll button and its caption are in plain vmin instead: they are the same size in every model.Math.random() → target rotationaccumulating 360° turnstransition with overshoot easing<div class="table">
<div class="view">
<div class="scene">
<div class="cube">
<div>1</div><div>2</div><div>6</div>
<div>5</div><div>3</div><div>4</div>
</div>
</div>
</div>
<div class="controls">
<output class="caption">Click roll</output>
<div class="row">
<button type="button">Roll</button>
</div>
</div>
</div>
.table {
/* one base unit: every length in the die 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 under it is in
plain vmin, because it is the same object in every model. */
--u: 0.21vmin;
display: grid;
justify-items: center;
gap: 4vmin; /* the band's gap between the model and the control zone */
font-family: Inter, system-ui;
}
/* the model box: the same height in every model that has controls, so the zone below it lands
in the same place whatever the model is */
.view {
display: grid;
place-items: center;
height: 44vmin;
}
.scene {
perspective: calc(700 * var(--u));
}
.cube {
--s: calc(120 * var(--u));
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
transform:
rotateX(-22deg) rotateY(-28deg) /* camera */
rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); /* the roll, from JS */
transition: transform 1.1s cubic-bezier(0.2, 0.9, 0.3, 1.15);
}
.cube > * {
position: absolute;
inset: 0;
display: grid;
place-items: center;
/* an inner line plus a soft shade instead of a hard border: squeezed side-on, a calc(2 * var(--u)) line
breaks up, the shade survives */
box-shadow: inset 0 0 0 calc(1.5 * var(--u)) #c9cbe0, inset 0 0 calc(8 * var(--u)) rgb(150 154 190 / 0.45);
border-radius: calc(16 * var(--u));
background: radial-gradient(circle at 30% 30%, #fff, #dfe1f0);
color: #1a1d33;
font: 900 calc(54 * var(--u)) Inter, system-ui;
transform-style: preserve-3d;
}
/* Rounded faces leave a hole where three corners meet. A square plate just behind each face
forms a slightly smaller inner cube that plugs the gaps. */
.cube > *::before {
content: '';
position: absolute;
inset: calc(8 * var(--u));
background: #d3d6ea;
transform: translateZ(calc(-8 * var(--u)));
}
/* 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 control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the die's own unit. The caption is on its own line above the
row, and its line box never changes height, so a new result cannot move the die. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
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;
}
const cube = document.querySelector('.cube');
const out = document.querySelector('output');
// rotation [x, y] that brings each value to the front
// (faces in the markup: front 1, right 2, back 6, left 5, top 3, bottom 4)
const views = { 1: [0, 0], 2: [0, -90], 6: [0, -180], 5: [0, 90], 3: [-90, 0], 4: [90, 0] };
let turns = 0;
document.querySelector('button').addEventListener('click', () => {
const value = 1 + Math.floor(Math.random() * 6);
const [x, y] = views[value];
turns += 2; // two extra full turns per roll
cube.style.setProperty('--rx', x + 360 * turns + 'deg');
cube.style.setProperty('--ry', y + 360 * turns + 'deg');
out.textContent = 'Rolling…';
setTimeout(() => (out.textContent = 'You rolled ' + value), 1100);
});