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.
A chunky switch whose knob is a real cube. Checked, it tips over its right-hand edge onto the other half of the track, bringing the moon face up, and the track changes colour.
Your edited version
rotateX(58deg)). Its thickness is five copies of the pill stacked 2.4px apart downward, lighter at the top: stacked layers are the cheap way to extrude a rounded shape.transform-origin is its bottom right edge: 100% 50% -17px (the third value is Z, from the cube’s centre).rotateY(90deg) about that edge: exactly how a cube tips over. The centre swings up on an arc and lands one side further on, with no translate and no keyframes. The track is two cube widths long, so one tip is the whole travel.transform and opacity animate: the track’s “on” colour is a layer that fades in, and Off/On are two words in one grid cell. The whole thing is one <label> around a real role="switch" checkbox.transform-origin on the cube’s bottom edgeone rotateY(90deg) = a real rollstacked layers for the track’s depthcolour swap by cross-fading opacity<label class="toggle">
<input type="checkbox" role="switch" />
<span class="track" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i>
<span class="top"></span>
<i class="shadow"></i>
<span class="knob">
<span class="cube"><i><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.9 4.9 1.4 1.4"/><path d="m17.7 17.7 1.4 1.4"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.3 17.7-1.4 1.4"/><path d="m19.1 4.9-1.4 1.4"/></svg></i><i></i><i></i><i><svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></i><i></i><i></i></span>
</span>
</span>
<span class="text">Dark mode<small aria-hidden="true"><span>Off</span><span>On</span></small></span>
</label>
/* one label: the static hit target around a real checkbox */
.toggle {
display: flex;
align-items: center;
gap: 18px;
padding: 18px 10px;
color: #eceefb;
font-size: 15px;
font-weight: 800;
cursor: pointer;
perspective: 800px;
transform-style: preserve-3d;
user-select: none;
}
.toggle * {
pointer-events: none;
}
.toggle input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
/* 34px cube + 8px padding: two cube widths long, so one roll is the whole travel */
.track {
position: relative;
flex: none;
width: 84px;
height: 50px;
transform-style: preserve-3d;
transform: rotateX(58deg); /* lying on the floor, seen from above */
}
/* the thickness: copies of the pill stacked downward, darker as they go */
.track > i:not(.shadow) {
position: absolute;
inset: 0;
border-radius: 25px;
}
.track > i:nth-child(1) { background: color-mix(in srgb, #eceefb 15%, #101326); transform: translateZ(-12px); }
.track > i:nth-child(2) { background: color-mix(in srgb, #eceefb 18%, #101326); transform: translateZ(-9.6px); }
.track > i:nth-child(3) { background: color-mix(in srgb, #eceefb 21%, #101326); transform: translateZ(-7.2px); }
.track > i:nth-child(4) { background: color-mix(in srgb, #eceefb 24%, #101326); transform: translateZ(-4.8px); }
.track > i:nth-child(5) { background: color-mix(in srgb, #eceefb 27%, #101326); transform: translateZ(-2.4px); }
/* the top: a groove. "On" is a second layer that only fades in */
.top {
position: absolute;
inset: 0;
border-radius: 25px;
background: color-mix(in srgb, #949bc0 30%, #141830);
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, #eceefb 30%, #141830), inset 0 3px 8px rgb(0 0 0 / 0.35);
}
.top::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(90deg, #8b6cff, #ff4d9d);
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, #8b6cff, #fff 35%), inset 0 3px 8px rgb(0 0 0 / 0.35);
opacity: 0;
transition: opacity 0.4s 0.1s;
}
.toggle input:checked ~ .track .top::before {
opacity: 1;
}
.toggle input:focus-visible ~ .track .top {
outline: 2px solid #2ee6d6;
outline-offset: 4px;
}
/* the cube's shadow, sliding along with it */
.shadow {
position: absolute;
top: 8px;
left: 8px;
width: 34px;
height: 34px;
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), transparent);
transform: translateZ(0.5px) scale(1.25);
transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
}
.toggle input:checked ~ .track .shadow {
transform: translateZ(0.5px) translateX(34px) scale(1.25);
}
/* lifted by half a side, so the cube stands on the track */
.knob {
position: absolute;
top: 8px;
left: 8px;
width: 34px;
height: 34px;
transform-style: preserve-3d;
transform: translateZ(17px);
}
/* the pivot is the cube's bottom right edge: x = 100%, z = -17px from its centre */
.cube {
--s: 34px;
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: 100% 50% -17px;
transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
}
/* checked: a quarter turn about that edge, a real roll */
.toggle input:checked ~ .track .cube {
transform: rotateY(90deg);
}
/* in the track's own space +z is up: face 1 is the top (sun), 4 the left (moon) */
/* 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)); }
.cube > i {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 5px;
background: linear-gradient(145deg, #fff, #dcd9ef);
transform-style: preserve-3d;
}
/* rounded faces leave holes at the corners: a square plate behind each one plugs them */
.cube > i::before {
content: '';
position: absolute;
inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
background: #c9c5e2;
transform: translateZ(-3px);
}
.cube > i:nth-child(2) { background: linear-gradient(145deg, #eceaf8, #cbc7e3); }
.cube > i:nth-child(6) { background: linear-gradient(#d9d5ee, #b9b4d6); }
/* the sun and the moon inside a rim of the cube's own colour (like a keycap): at a rounded
corner a sliver of the side face shows, and a dark moon there looked like a hole */
.cube > i:nth-child(1) {
background: radial-gradient(circle, #fff6df, #ffe3a6);
box-shadow: inset 0 0 0 2.5px #dcd9ef;
color: #e08a00;
}
.cube > i:nth-child(4) {
background: radial-gradient(circle at 40% 35%, #34396e, #181b3c);
box-shadow: inset 0 0 0 2.5px #d4d0ea;
color: #e9e4ff;
}
.cube svg {
width: 22px;
height: 22px;
}
.cube > i:nth-child(4) svg {
width: 19px;
height: 19px;
}
/* the label and its state: two words in one grid cell, cross-faded */
.text {
display: grid;
gap: 2px;
line-height: 1.1;
}
.text small {
display: grid;
color: #949bc0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.text small span {
grid-area: 1 / 1;
transition: opacity 0.3s;
}
.text small span + span {
color: #8b6cff;
opacity: 0;
}
.toggle input:checked ~ .text span:first-child { opacity: 0; }
.toggle input:checked ~ .text span + span { opacity: 1; }
@use 'sass:math';
@use '../mixins' as *;
$s: 34px; // the cube
$pad: 8px; // space around it inside the track
$w: $s * 2 + $pad * 2; // track: two cube widths, so one roll is exactly one side long
$h: $s + $pad * 2;
$depth: 5; // layers under the top, 2.4px apart
// The whole switch is one <label>: the static hit target around a real checkbox.
.d-toggle {
display: flex;
align-items: center;
gap: 18px;
padding: 18px 10px; // room for the cube, which stands up out of the tilted track
color: var(--text);
font-size: 15px;
font-weight: 800;
cursor: pointer;
transform-style: preserve-3d;
user-select: none;
* {
pointer-events: none;
}
input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
&__track {
position: relative;
flex: none;
width: $w;
height: $h;
transform-style: preserve-3d;
// lying on the floor, seen from above
transform: rotateX(58deg);
// the housing's thickness: copies of the pill stacked downward, darker as they go
> i:not(.d-toggle__shadow) {
position: absolute;
inset: 0;
border-radius: math.div($h, 2);
}
@for $n from 1 through $depth {
> i:nth-child(#{$n}) {
background: color-mix(in srgb, var(--text) #{12% + $n * 3%}, color-mix(in srgb, var(--surface-solid) 80%, #000));
transform: translateZ(-2.4px * ($depth + 1 - $n));
}
}
}
// The top: a groove the cube rolls in. Off and on colours are two layers; the switch only
// fades the "on" one in, so nothing but opacity animates.
&__top {
position: absolute;
inset: 0;
border-radius: math.div($h, 2);
background: color-mix(in srgb, var(--muted) 30%, var(--surface-solid));
box-shadow:
inset 0 0 0 1.5px color-mix(in srgb, var(--text) 30%, var(--surface-solid)),
inset 0 3px 8px rgb(0 0 0 / 0.35);
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--hot));
box-shadow:
inset 0 0 0 1.5px color-mix(in srgb, var(--accent), #fff 35%),
inset 0 3px 8px rgb(0 0 0 / 0.35);
opacity: 0;
transition: opacity 0.4s 0.1s;
}
}
input:checked ~ &__track &__top::before {
opacity: 1;
}
input:focus-visible ~ &__track &__top {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
}
// the cube's shadow on the groove, sliding along with it
&__shadow {
position: absolute;
top: $pad;
left: $pad;
width: $s;
height: $s;
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), transparent);
transform: translateZ(0.5px) scale(1.25);
transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
}
input:checked ~ &__track &__shadow {
transform: translateZ(0.5px) translateX($s) scale(1.25);
}
// lifted by half a side, so the cube stands on the top instead of sinking through it
&__knob {
position: absolute;
top: $pad;
left: $pad;
width: $s;
height: $s;
transform-style: preserve-3d;
transform: translateZ($s * 0.5);
}
// The roll: the pivot is the cube's bottom right edge (x = 100%, z = −s/2 from its centre).
// A quarter turn about that edge is exactly how a cube tips over: the centre swings up on an
// arc and lands one side further on. No translate, no guessing the path.
&__cube {
--s: #{$s};
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: 100% 50% ($s * -0.5);
transition: transform 0.5s cubic-bezier(0.55, 0, 0.35, 1);
@include cube-faces(var(--s));
// faces in cube-faces order: top (+z, the sun), right, bottom, left (the moon, which ends on
// top after the roll), back, front
i {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 5px;
background: linear-gradient(145deg, #fff, #dcd9ef);
@include solid-core(3px, #c9c5e2);
svg {
width: 22px;
height: 22px;
}
}
i:nth-child(2),
i:nth-child(4) {
background: linear-gradient(145deg, #eceaf8, #cbc7e3);
}
i:nth-child(6) {
background: linear-gradient(#d9d5ee, #b9b4d6);
}
// The sun and the moon are painted inside a rim of the cube's own colour, like a keycap: at
// a rounded corner you see a sliver of the side face next to it, and a dark moon face there
// looked like a hole into the cube.
i:nth-child(1) {
background: radial-gradient(circle, #fff6df, #ffe3a6);
box-shadow: inset 0 0 0 2.5px #dcd9ef;
color: #e08a00;
}
i:nth-child(4) {
background: radial-gradient(circle at 40% 35%, #34396e, #181b3c);
box-shadow: inset 0 0 0 2.5px #d4d0ea;
color: #e9e4ff;
svg {
width: 19px;
height: 19px;
}
}
}
input:checked ~ &__track &__cube {
transform: rotateY(90deg);
}
// the label and its state word, the two words cross-faded in one grid cell
&__text {
display: grid;
gap: 2px;
line-height: 1.1;
small {
display: grid;
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
span {
grid-area: 1 / 1;
transition: opacity 0.3s;
}
span + span {
color: var(--accent);
opacity: 0;
}
}
input:checked ~ &__text span:first-child {
opacity: 0;
}
input:checked ~ &__text span + span {
opacity: 1;
}
}