Magnetic button
CSS + JSInside its field the button shifts and leans toward the pointer while its label floats higher, so the two slide apart. JS only reports the pointer position; release it and a springy transition takes it home.
The tab panels are the four long sides of a prism. Radio inputs and labels pick a tab; each one sets a step count and the prism turns to that face.
Your edited version
~ general sibling combinator can reach both the nav and the prism from any of them.n gets rotateX(n * -90deg) translateZ(46px).translateZ(-46px)) so the panel currently in front sits exactly at z = 0 and its text stays sharp.n sets --step to n - 1 on the prism; rotateX(calc(var(--step) * 90deg)) turns to that face, and one shared transition animates every possible jump.backface-visibility: hidden on the panels stops the back faces from showing through as the prism turns.radio:checked ~ sibling--step × 90degtranslateZ(-h / 2) keeps the front at z = 0backface-visibility: hidden<div class="tabs">
<input type="radio" name="tabs" id="tab-0" aria-label="Front" checked />
<input type="radio" name="tabs" id="tab-1" aria-label="Bottom" />
<input type="radio" name="tabs" id="tab-2" aria-label="Back" />
<input type="radio" name="tabs" id="tab-3" aria-label="Top" />
<div class="tabs-nav">
<label for="tab-0">Front</label>
<label for="tab-1">Bottom</label>
<label for="tab-2">Back</label>
<label for="tab-3">Top</label>
</div>
<div class="tabs-view">
<div class="tabs-prism">
<section style="--i:0;--c:#8b6cff"><b>Front</b><p>The face you start on. It sits at z = 0, so its text stays sharp.</p></section>
<section style="--i:1;--c:#2ee6d6"><b>Bottom</b><p>A quarter turn forward brings the bottom face up to the front.</p></section>
<section style="--i:2;--c:#ff4d9d"><b>Back</b><p>Half a turn. Placed with a half turn too, so it reads upright.</p></section>
<section style="--i:3;--c:#ffb547"><b>Top</b><p>Three quarter turns, all driven by one custom property.</p></section>
</div>
</div>
</div>
.tabs {
position: relative;
display: grid;
gap: 20px;
width: 204px;
}
/* real radios, invisible but focusable; they come first so ~ can reach everything else */
.tabs input {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.tabs-nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 3px;
padding: 3px;
border: 1px solid #262b4a;
border-radius: 10px;
background: rgb(22 26 51 / 0.6);
}
.tabs-nav label {
padding: 5px 0;
border-radius: 7px;
color: #949bc0;
font-size: 11px;
font-weight: 700;
text-align: center;
cursor: pointer;
transition: background 0.25s, color 0.25s;
}
.tabs-nav label:hover {
color: #eceefb;
}
/* the window the prism is seen through: static, and NOT clipped (clipping would flatten it) */
.tabs-view {
height: 92px;
perspective: 520px;
pointer-events: none;
}
/* pulled back by half its depth so the face in front sits exactly at z = 0 */
.tabs-prism {
position: relative;
height: 100%;
transform-style: preserve-3d;
transform: translateZ(-46px) rotateX(calc(var(--step, 0) * 90deg));
transition: transform 0.75s cubic-bezier(0.3, 1.2, 0.4, 1);
}
/* face n: n quarter turns backward, then out by half the depth -> front, bottom, back, top */
.tabs-prism section {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 4px;
padding: 0 16px;
border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
border-radius: 10px;
background:
linear-gradient(140deg, color-mix(in srgb, var(--c) 34%, transparent), transparent 70%),
rgb(11 13 24 / 0.88);
backface-visibility: hidden;
transform: rotateX(calc(var(--i) * -90deg)) translateZ(46px);
}
.tabs-prism section b {
color: var(--c);
font-size: 15px;
font-weight: 800;
}
.tabs-prism section p {
margin: 0;
color: #949bc0;
font-size: 11.5px;
line-height: 1.4;
}
/* one rule per tab: the nth radio turns the prism n-1 steps */
.tabs input:nth-of-type(1):checked ~ .tabs-view .tabs-prism { --step: 0; }
.tabs input:nth-of-type(2):checked ~ .tabs-view .tabs-prism { --step: 1; }
.tabs input:nth-of-type(3):checked ~ .tabs-view .tabs-prism { --step: 2; }
.tabs input:nth-of-type(4):checked ~ .tabs-view .tabs-prism { --step: 3; }
/* ...and lights the matching label */
.tabs input:nth-of-type(1):checked ~ .tabs-nav label:nth-of-type(1),
.tabs input:nth-of-type(2):checked ~ .tabs-nav label:nth-of-type(2),
.tabs input:nth-of-type(3):checked ~ .tabs-nav label:nth-of-type(3),
.tabs input:nth-of-type(4):checked ~ .tabs-nav label:nth-of-type(4) {
background: #8b6cff;
color: #fff;
}
.tabs input:nth-of-type(1):focus-visible ~ .tabs-nav label:nth-of-type(1),
.tabs input:nth-of-type(2):focus-visible ~ .tabs-nav label:nth-of-type(2),
.tabs input:nth-of-type(3):focus-visible ~ .tabs-nav label:nth-of-type(3),
.tabs input:nth-of-type(4):focus-visible ~ .tabs-nav label:nth-of-type(4) {
outline: 2px solid #2ee6d6;
outline-offset: 2px;
}
$w: 204px;
$h: 92px; // face height = prism depth (square cross-section)
.d-tabs {
position: relative;
display: grid;
gap: 20px;
width: $w;
// Real radios, invisible but focusable. They come first so `~` can reach everything else.
input {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
&__nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 3px;
padding: 3px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-solid) 60%, transparent);
label {
padding: 5px 0;
border-radius: 7px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-align: center;
cursor: pointer;
transition:
background 0.25s,
color 0.25s;
&:hover {
color: var(--text);
}
}
}
// The window the prism is seen through. Static, and not clipped (clipping would flatten).
&__view {
height: $h;
perspective: 520px;
pointer-events: none;
}
// Pulled back by half its depth so the face in front sits exactly at z = 0.
&__prism {
position: relative;
height: 100%;
transform-style: preserve-3d;
transform: translateZ($h * -0.5) rotateX(calc(var(--step, 0) * 90deg));
transition: transform 0.75s cubic-bezier(0.3, 1.2, 0.4, 1);
// Face n: n quarter turns backwards, then out by half the depth -> front, bottom, back, top.
section {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 4px;
padding: 0 16px;
border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
border-radius: 10px;
background:
linear-gradient(140deg, color-mix(in srgb, var(--c) 34%, transparent), transparent 70%),
color-mix(in srgb, var(--surface-solid) 88%, transparent);
backface-visibility: hidden;
transform: rotateX(calc(var(--i) * -90deg)) translateZ($h * 0.5);
b {
color: var(--c);
font-size: 15px;
font-weight: 800;
}
p {
margin: 0;
color: var(--muted);
font-size: 11.5px;
line-height: 1.4;
}
}
}
// One rule per tab: the nth radio turns the prism n-1 steps and lights the nth label.
@for $n from 1 through 4 {
input:nth-of-type(#{$n}):checked ~ &__view &__prism {
--step: #{$n - 1};
}
input:nth-of-type(#{$n}):checked ~ &__nav label:nth-of-type(#{$n}) {
background: var(--accent);
color: #fff;
}
input:nth-of-type(#{$n}):focus-visible ~ &__nav label:nth-of-type(#{$n}) {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
}
}