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.
Interactive with no JavaScript: checked radios steer the cube through sibling selectors.
Your edited version
<input type="radio"> elements hold the state — the browser handles clicks and arrow keys. They are hidden visually, not with display: none, so they still take focus and a screen reader still finds them. Keep the aria-labels: they give each radio its face’s full name.<label for> in the control zone: clicking a label checks its radio. The labels are styled by the shared control block, copied unchanged, so each one looks and measures like every other model’s buttons. The letters are the cube-puzzle names for the faces: Front, Right, Back, Left, Up, Down.input:checked ~ .view .cube turns the cube, input:checked ~ .controls label lights its label with the selected pill, and the same rule names the face in the caption.transition animates between them.input:focus-visible ~ .controls label rings the label of the radio that has focus.--u, so it is the same share of a gallery card, the editor and a recording canvas. The control zone under it is in plain vmin, because it is the same object at the same size in every model that has one.:checked ~ sibling selectortransition on transform<label for> as the button<div class="band">
<input type="radio" name="face" id="face-f" aria-label="Front" checked>
<input type="radio" name="face" id="face-r" aria-label="Right">
<input type="radio" name="face" id="face-b" aria-label="Back">
<input type="radio" name="face" id="face-l" aria-label="Left">
<input type="radio" name="face" id="face-u" aria-label="Up">
<input type="radio" name="face" id="face-d" aria-label="Down">
<div class="view">
<div class="cube">
<div>Front</div><div>Right</div><div>Back</div>
<div>Left</div><div>Up</div><div>Down</div>
</div>
</div>
<div class="controls">
<output class="caption"><span>Front</span><span>Right</span><span>Back</span><span>Left</span><span>Up</span><span>Down</span></output>
<div class="row">
<label for="face-f" title="Front">F</label>
<label for="face-r" title="Right">R</label>
<label for="face-b" title="Back">B</label>
<label for="face-l" title="Left">L</label>
<label for="face-u" title="Up">U</label>
<label for="face-d" title="Down">D</label>
</div>
</div>
</div>
/* The model box and the zone stand in one stack, so the zone is the same distance below the
model in every model and the pair is centred as the band says. */
.band {
/* one base unit: every length in the cube is a multiple of it, so the cube is the same share
of a card, the editor, a full screen and a recording canvas. The zone under it is in plain
vmin, because it is the same object in every model. Sized so the cube fills the model box:
at 0.22 it left 8vmin of the box empty over the cube, and the centred stack sat 4vmin low. */
--u: 0.29vmin;
position: relative;
display: grid;
justify-items: center;
gap: 4vmin;
font-family: Inter, system-ui, sans-serif;
}
/* the model box: the same height in every model that has controls */
.view {
display: grid;
place-items: center;
height: 50vmin;
perspective: calc(800 * var(--u));
}
/* the radios: out of sight and out of the grid, but still focusable and still read out */
.band > input {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
/* 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;
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 label is not a button: it is inline, so it would ignore the block's height. A grid gives it
the height and puts its letter in the middle, as a button does. */
.controls label {
display: grid;
place-items: center;
}
/* the caption: one line, always the same height, so naming a new face cannot move the cube */
.caption span { display: none; }
.cube {
--s: calc(120 * var(--u));
--view: rotateX(-18deg) rotateY(-22deg);
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
transform: var(--view);
transition: transform 0.9s cubic-bezier(0.3, 1.3, 0.5, 1);
}
/* the whole trick: a checked radio restyles a LATER sibling, and anything inside one */
input:nth-of-type(1):checked ~ .view .cube { transform: var(--view) rotateY(0deg); }
input:nth-of-type(2):checked ~ .view .cube { transform: var(--view) rotateY(-90deg); }
input:nth-of-type(3):checked ~ .view .cube { transform: var(--view) rotateY(-180deg); }
input:nth-of-type(4):checked ~ .view .cube { transform: var(--view) rotateY(90deg); }
input:nth-of-type(5):checked ~ .view .cube { transform: var(--view) rotateX(-90deg); }
input:nth-of-type(6):checked ~ .view .cube { transform: var(--view) rotateX(90deg); }
/* and the same rule again, to show the face's name */
input:nth-of-type(1):checked ~ .controls .caption span:nth-child(1),
input:nth-of-type(2):checked ~ .controls .caption span:nth-child(2),
input:nth-of-type(3):checked ~ .controls .caption span:nth-child(3),
input:nth-of-type(4):checked ~ .controls .caption span:nth-child(4),
input:nth-of-type(5):checked ~ .controls .caption span:nth-child(5),
input:nth-of-type(6):checked ~ .controls .caption span:nth-child(6) { display: inline; }
/* and again, to light the checked radio's label: the block's selected pill */
input:nth-of-type(1):checked ~ .controls label:nth-of-type(1),
input:nth-of-type(2):checked ~ .controls label:nth-of-type(2),
input:nth-of-type(3):checked ~ .controls label:nth-of-type(3),
input:nth-of-type(4):checked ~ .controls label:nth-of-type(4),
input:nth-of-type(5):checked ~ .controls label:nth-of-type(5),
input:nth-of-type(6):checked ~ .controls label:nth-of-type(6) {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* the face already showing answers the pointer too: the rule above sets the background SHORTHAND,
so its gradient is an IMAGE painted over the colour .controls label:hover sets, and the one
label already chosen was the one label that did nothing under the pointer. Darker, so white
keeps passing on it. */
input:nth-of-type(1):checked ~ .controls label:nth-of-type(1):hover,
input:nth-of-type(2):checked ~ .controls label:nth-of-type(2):hover,
input:nth-of-type(3):checked ~ .controls label:nth-of-type(3):hover,
input:nth-of-type(4):checked ~ .controls label:nth-of-type(4):hover,
input:nth-of-type(5):checked ~ .controls label:nth-of-type(5):hover,
input:nth-of-type(6):checked ~ .controls label:nth-of-type(6):hover {
background-image: linear-gradient(135deg, #5730dd, #b31859);
}
/* and once more for the keyboard: the block's focus ring, on the label of the focused radio */
input:nth-of-type(1):focus-visible ~ .controls label:nth-of-type(1),
input:nth-of-type(2):focus-visible ~ .controls label:nth-of-type(2),
input:nth-of-type(3):focus-visible ~ .controls label:nth-of-type(3),
input:nth-of-type(4):focus-visible ~ .controls label:nth-of-type(4),
input:nth-of-type(5):focus-visible ~ .controls label:nth-of-type(5),
input:nth-of-type(6):focus-visible ~ .controls label:nth-of-type(6) {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
.cube > * {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font: 700 calc(16 * var(--u)) Inter, system-ui;
background: rgb(46 230 214 / 0.25);
/* an inner line plus a soft glow instead of a hard border: squeezed side-on, a one-unit line
breaks up, the glow survives */
box-shadow: inset 0 0 0 calc(1 * var(--u)) rgb(46 230 214 / 0.8), inset 0 0 calc(12 * var(--u)) rgb(46 230 214 / 0.35);
}
/* 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)); }