Pure CSS Buttons & forms #controls #hover #button #keyboard

3D keycaps in pure CSS

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.

  • 194 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The keys stand on a plate lying on the floor: rotateX(54deg) rotateZ(-30deg) turns it and lays it back, and preserve-3d lets everything on it rise up along its Z axis.
  2. Every length is a multiple of one base unit, --u, tied to the canvas — the plate, the keys, the slope of their sides — so the deck is the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  3. A keycap is a truncated pyramid. Each sloped side is a 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²).
  4. The other three sides are the same trapezoid turned around the key’s centre: translateY(-22 units) rotate(90deg) translateY(22 units) moves the pivot to the middle, rotates, and moves it back.
  5. The <button> is the hit target and never moves; only the .cap inside it (with pointer-events: none) sinks with translateZ(-7 units). An empty ::after lid where the top sits at rest makes the top clickable too.
  6. The glow is two layers that only fade with 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.

Key techniques

  • trapezoid sides: clip-path + rotateX(atan(h / inset))
  • rotate each side around the key centre
  • static <button>, cap sinks with translateZ
  • glow faded with opacity

Copy-paste code

HTML 8 lines

<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>

CSS 194 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the deck is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.54vmin;
  perspective: calc(800 * var(--u));
  /* flat, not preserve-3d: the back of the tilted plate lies behind z = 0, and any box around the
     scene that shares its 3D space (a page wrapper with preserve-3d) would sit in front of the
     keys there and take their pointer. Flat, the deck is drawn into the scene's own plane, on top. */
  transform-style: flat;
  /* 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. Two by two, not one row of four:
   a row laid back this far is three and a half times wider than it is tall, so at the 92vmin
   width limit the deck stands on the 40vmin floor with nothing to spare */
.deck {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: calc(7 * var(--u));
  padding: calc(9 * var(--u));
  border-radius: calc(9 * var(--u));
  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 calc(1 * var(--u)) 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: calc(8 * var(--u));
  left: calc(8 * var(--u));
  height: calc(12 * var(--u));
  transform-origin: 50% 0;
  transform: rotateX(-90deg);
}

.deck::after {
  top: calc(8 * var(--u));
  right: 100%;
  bottom: calc(8 * var(--u));
  width: calc(12 * var(--u));
  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: calc(44 * var(--u));
  height: calc(44 * var(--u));
  padding: 0;
  border: 0;
  border-radius: calc(6 * var(--u));
  background: none;
  color: var(--ink);
  font: 800 calc(16 * var(--u))/1 Inter, 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: calc(6 * var(--u));
  transform: translateZ(calc(13 * var(--u)));
}

.key:focus-visible {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(3 * var(--u));
}

/* 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: calc(7 * var(--u));
  background: color-mix(in srgb, var(--c), #000 30%);
  transform: translateZ(calc(12 * var(--u)));
}

/* one sloped side: 13 units high, 6 units inset, so it is √(13² + 6²) = 14.32 units long,
   stands on the key's front edge and tips up by atan(13 / 6) = 65.2deg */
.cap i {
  position: absolute;
  top: calc(29.68 * var(--u)); /* 44 - 14.32: its bottom edge is the key's front edge */
  left: 0;
  width: calc(44 * var(--u));
  height: calc(14.32 * var(--u));
  clip-path: polygon(0 100%, 100% 100%, calc(100% - 6 * var(--u)) 0, calc(6 * var(--u)) 0);
  transform-origin: 50% 100%;
}

/* the same side turned around the key's centre (22 units 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(calc(-22 * var(--u))) rotate(0deg) translateY(calc(22 * var(--u))) 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(calc(-22 * var(--u))) rotate(90deg) translateY(calc(22 * var(--u))) 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(calc(-22 * var(--u))) rotate(180deg) translateY(calc(22 * var(--u))) 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(calc(-22 * var(--u))) rotate(270deg) translateY(calc(22 * var(--u))) rotateX(-65.2deg);
}

/* the top: slightly dished, with the legend */
.cap b {
  position: absolute;
  inset: calc(6 * var(--u));
  display: grid;
  place-items: center;
  border-radius: calc(4 * var(--u));
  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 calc(1 * var(--u)) color-mix(in srgb, var(--c), #fff 18%);
  text-shadow: 0 calc(1 * var(--u)) 0 color-mix(in srgb, var(--c), #000 40%);
  transform: translateZ(calc(13 * var(--u)));
}

/* 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: calc(-9 * var(--u));
  border-radius: calc(14 * var(--u));
  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(calc(0.5 * var(--u)));
  transition: opacity 0.2s;
}

/* pressed: the cap sinks into the plate, the lights come on */
.key:is(:hover, :focus-visible) .cap {
  transform: translateZ(calc(-7 * var(--u)));
}

.key:is(:hover, :focus-visible) .cap b::after,
.key:is(:hover, :focus-visible) .glow {
  opacity: 1;
}

.key:active .cap {
  transform: translateZ(calc(-10 * var(--u)));
}
Standalone snippet · plain CSS, no build step, no dependencies.