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.
Mechanical keycaps with sloped sides on a tilted plate. Hover or focus a key and its cap sinks into the plate with a glow; the button itself never moves, only the cap inside it.
Your edited version
rotateX(54deg) rotateZ(-30deg) turns it and lays it back, and preserve-3d lets everything on it rise up along its Z axis.clip-path trapezoid standing on an edge of the key, hinged there (transform-origin: 50% 100%) and tipped up by atan(height / inset) ≈ 65°, so its top edge lands exactly on the top face. Its length is √(height² + inset²).translateY(-22px) rotate(90deg) translateY(22px) moves the pivot to the middle, rotates, and moves it back.<button> is the hit target and never moves; only the .cap inside it (with pointer-events: none) sinks with translateZ(-7px). An empty ::after lid where the top sits at rest makes the top clickable too.opacity: a teal pool on the plate and a wash over the legend. :focus-visible presses the key as well, so it works from the keyboard.trapezoid sides: clip-path + rotateX(atan(h / inset))rotate each side around the key centrestatic <button>, cap sinks with translateZglow faded with opacity<div class="scene">
<div class="deck">
<button type="button" class="key"><i class="glow"></i><span class="cap"><i></i><i></i><i></i><i></i><b>C</b></span></button>
<button type="button" class="key"><i class="glow"></i><span class="cap"><i></i><i></i><i></i><i></i><b>S</b></span></button>
<button type="button" class="key"><i class="glow"></i><span class="cap"><i></i><i></i><i></i><i></i><b>S</b></span></button>
<button type="button" class="key hot"><i class="glow"></i><span class="cap"><i></i><i></i><i></i><i></i><b>3D</b></span></button>
</div>
</div>
.scene {
perspective: 800px;
transform-style: preserve-3d;
/* the plate is tilted, so parts of it lie behind z = 0: only the keys may take the pointer */
pointer-events: none;
}
/* a small keyboard plate lying on the floor, turned a little */
.deck {
position: relative;
display: flex;
gap: 7px;
padding: 9px;
border-radius: 9px;
background:
linear-gradient(160deg, rgb(255 255 255 / 0.07), transparent 60%),
color-mix(in srgb, #8b6cff 12%, #141830);
box-shadow: inset 0 0 0 1px rgb(140 150 220 / 0.34);
transform-style: preserve-3d;
transform: rotateX(54deg) rotateZ(-30deg); /* read right to left: turn it, then lay it back */
}
/* the plate's thickness: its front and left edges hang down from the top (-z) */
.deck::before,
.deck::after {
content: '';
position: absolute;
background: color-mix(in srgb, #8b6cff 22%, #0e1122);
}
.deck::before {
top: 100%;
right: 8px;
left: 8px;
height: 12px;
transform-origin: 50% 0;
transform: rotateX(-90deg);
}
.deck::after {
top: 8px;
right: 100%;
bottom: 8px;
width: 12px;
background: color-mix(in srgb, #8b6cff 18%, #0b0d1a);
transform-origin: 100% 50%;
transform: rotateY(90deg);
}
/* the button is the hit target and never moves */
.key {
--c: color-mix(in srgb, #8b6cff 38%, #141830);
--ink: #eceefb;
position: relative;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 6px;
background: none;
color: var(--ink);
font: 800 16px/1 system-ui, sans-serif;
cursor: pointer;
pointer-events: auto;
transform-style: preserve-3d;
}
.key.hot {
--c: #ff4d9d;
--ink: #fff;
}
/* an invisible lid where the cap's top is at rest, so pointing at the top hits the button too */
.key::after {
content: '';
position: absolute;
inset: 6px;
transform: translateZ(13px);
}
.key:focus-visible {
outline: 2px solid #2ee6d6;
outline-offset: 3px;
}
/* the cap: four sloped sides and a top, moved as one */
.cap {
position: absolute;
inset: 0;
pointer-events: none;
transform-style: preserve-3d;
transition: transform 0.16s cubic-bezier(0.3, 0.7, 0.4, 1);
}
/* a square plate just under the top: plugs the corners the rounded top leaves open */
.cap::before {
content: '';
position: absolute;
inset: 7px;
background: color-mix(in srgb, var(--c), #000 30%);
transform: translateZ(12px);
}
/* one sloped side: 13px high, 6px inset, so it is √(13² + 6²) = 14.32px long,
stands on the key's front edge and tips up by atan(13 / 6) = 65.2deg */
.cap i {
position: absolute;
top: 29.68px; /* 44px - 14.32px: its bottom edge is the key's front edge */
left: 0;
width: 44px;
height: 14.32px;
clip-path: polygon(0 100%, 100% 100%, calc(100% - 6px) 0, 6px 0);
transform-origin: 50% 100%;
}
/* the same side turned around the key's centre (22px above the hinge); shaded per side */
.cap i:nth-child(1) {
background: linear-gradient(color-mix(in srgb, var(--c), #000 20%), color-mix(in srgb, var(--c), #000 26%));
transform: translateY(-22px) rotate(0deg) translateY(22px) rotateX(-65.2deg);
}
.cap i:nth-child(2) {
background: linear-gradient(color-mix(in srgb, var(--c), #000 36%), color-mix(in srgb, var(--c), #000 42%));
transform: translateY(-22px) rotate(90deg) translateY(22px) rotateX(-65.2deg);
}
.cap i:nth-child(3) {
background: linear-gradient(color-mix(in srgb, var(--c), #000 2%), color-mix(in srgb, var(--c), #000 8%));
transform: translateY(-22px) rotate(180deg) translateY(22px) rotateX(-65.2deg);
}
.cap i:nth-child(4) {
background: linear-gradient(color-mix(in srgb, var(--c), #000 8%), color-mix(in srgb, var(--c), #000 14%));
transform: translateY(-22px) rotate(270deg) translateY(22px) rotateX(-65.2deg);
}
/* the top: slightly dished, with the legend */
.cap b {
position: absolute;
inset: 6px;
display: grid;
place-items: center;
border-radius: 4px;
background: radial-gradient(ellipse 80% 70% at 50% 45%, color-mix(in srgb, var(--c), #fff 10%), var(--c) 75%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c), #fff 18%);
text-shadow: 0 1px 0 color-mix(in srgb, var(--c), #000 40%);
transform: translateZ(13px);
}
/* the lit legend: a teal wash, faded in */
.cap b::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle, rgb(46 230 214 / 0.45), rgb(46 230 214 / 0.12));
opacity: 0;
transition: opacity 0.2s;
}
/* light spilling onto the plate around a pressed key */
.glow {
position: absolute;
inset: -9px;
border-radius: 14px;
background: radial-gradient(closest-side, rgb(46 230 214 / 0.75), rgb(46 230 214 / 0.25) 70%, transparent);
opacity: 0;
pointer-events: none;
transform: translateZ(0.5px);
transition: opacity 0.2s;
}
/* pressed: the cap sinks into the plate, the lights come on */
.key:is(:hover, :focus-visible) .cap {
transform: translateZ(-7px);
}
.key:is(:hover, :focus-visible) .cap b::after,
.key:is(:hover, :focus-visible) .glow {
opacity: 1;
}
.key:active .cap {
transform: translateZ(-10px);
}
@use 'sass:math';
@use 'sass:list';
$k: 44; // key footprint (px)
$in: 6; // how far the top face is inset from the footprint, on every side
$h: 13; // cap height
$slope: math.sqrt($h * $h + $in * $in) * 1px; // true length of a sloped side
$tilt: math.atan(math.div($h, $in)); // how steeply a side rises from the plate (≈ 65°)
$press: 7px; // key travel
// A small keyboard plate lying on the floor, seen from above and turned a little.
.d-keycaps {
transform-style: preserve-3d;
// The wrapper and the plate are flat boxes at z = 0: parts of the tilted plate lie behind that
// plane, and would steal the pointer from the keys there. Only the keys take the pointer.
pointer-events: none;
&__deck {
position: relative;
display: flex;
gap: 7px;
padding: 9px;
border-radius: 9px;
background:
linear-gradient(160deg, rgb(255 255 255 / 0.07), transparent 60%),
color-mix(in srgb, var(--accent) 12%, var(--surface-solid));
box-shadow: inset 0 0 0 1px var(--border-strong);
transform-style: preserve-3d;
// Read right to left: turn the plate on the floor, then lay the floor back.
transform: rotateX(54deg) rotateZ(-30deg);
// the plate's thickness: its front and left edges hang down from the top (−z)
&::before,
&::after {
content: '';
position: absolute;
background: color-mix(in srgb, var(--accent) 22%, color-mix(in srgb, var(--surface-solid) 70%, #000));
}
&::before {
top: 100%;
right: 8px;
left: 8px;
height: 12px;
transform-origin: 50% 0;
transform: rotateX(-90deg);
}
&::after {
top: 8px;
right: 100%;
bottom: 8px;
width: 12px;
background: color-mix(in srgb, var(--accent) 18%, color-mix(in srgb, var(--surface-solid) 55%, #000));
transform-origin: 100% 50%;
transform: rotateY(90deg);
}
}
// The <button> is the hit target and never moves. Its base lies on the plate; a see-through
// lid (::after) sits where the cap's top is at rest, so pointing at the top face hits it too.
&__key {
--c: color-mix(in srgb, var(--accent) 38%, var(--surface-solid));
--ink: var(--text);
position: relative;
width: $k * 1px;
height: $k * 1px;
padding: 0;
border: 0;
border-radius: 6px;
background: none;
color: var(--ink);
font: 800 16px/1 var(--font);
cursor: pointer;
pointer-events: auto;
transform-style: preserve-3d;
&::after {
content: '';
position: absolute;
inset: $in * 1px;
transform: translateZ($h * 1px);
}
&:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
&--hot {
--c: var(--hot);
--ink: #fff;
}
}
// the cap: four sloped sides and a top, moved as one
&__cap {
position: absolute;
inset: 0;
pointer-events: none;
transform-style: preserve-3d;
transition: transform 0.16s cubic-bezier(0.3, 0.7, 0.4, 1);
// a square plate just under the top face: plugs the corners the rounded top leaves open
&::before {
content: '';
position: absolute;
inset: ($in + 1) * 1px;
background: color-mix(in srgb, var(--c), #000 30%);
transform: translateZ(($h - 1) * 1px);
}
// Each side is a trapezoid (wide at the plate, narrow at the top) standing on the front edge
// of the footprint, hinged there and tipped up by $tilt, so its top edge lands exactly on the
// top face. The other three are the same side, turned around the key's centre.
i {
position: absolute;
top: $k * 1px - $slope;
left: 0;
width: $k * 1px;
height: $slope;
clip-path: polygon(0 100%, 100% 100%, calc(100% - #{$in * 1px}) 0, #{$in * 1px} 0);
transform-origin: 50% 100%;
}
// front, left, back, right: shaded as if lit from the back right
$shades: (26%, 42%, 8%, 14%);
@for $n from 1 through 4 {
i:nth-child(#{$n}) {
background: linear-gradient(color-mix(in srgb, var(--c), #000 #{list.nth($shades, $n) - 6%}), color-mix(in srgb, var(--c), #000 list.nth($shades, $n)));
transform: translateY($k * -0.5px) rotate(($n - 1) * 90deg) translateY($k * 0.5px) rotateX(-$tilt);
}
}
// the top: a slightly dished surface with the legend
b {
position: absolute;
inset: $in * 1px;
display: grid;
place-items: center;
border-radius: 4px;
background:
radial-gradient(ellipse 80% 70% at 50% 45%, color-mix(in srgb, var(--c), #fff 10%), var(--c) 75%),
var(--c);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c), #fff 18%);
text-shadow: 0 1px 0 color-mix(in srgb, var(--c), #000 40%);
transform: translateZ($h * 1px);
// the lit legend: a teal wash over the top, faded in when pressed
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle, color-mix(in srgb, var(--accent-2) 45%, transparent), color-mix(in srgb, var(--accent-2) 12%, transparent));
opacity: 0;
transition: opacity 0.2s;
}
}
}
// light spilling onto the plate around a pressed key
&__glow {
position: absolute;
inset: -9px;
border-radius: 14px;
background: radial-gradient(closest-side, color-mix(in srgb, var(--accent-2) 75%, transparent), color-mix(in srgb, var(--accent-2) 25%, transparent) 70%, transparent);
opacity: 0;
pointer-events: none;
transform: translateZ(0.5px);
transition: opacity 0.2s;
}
&__key:is(:hover, :focus-visible) &__cap {
transform: translateZ(-$press);
b::after {
opacity: 1;
}
}
&__key:is(:hover, :focus-visible) &__glow {
opacity: 1;
}
&__key:active &__cap {
transform: translateZ(-$press - 3px);
}
}