Pure CSS Buttons & forms #controls #hover #button #keyboard #sass-loop

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.

  • 185 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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. 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²).
  3. The other three sides are the same trapezoid turned around the key’s centre: translateY(-22px) rotate(90deg) translateY(22px) moves the pivot to the middle, rotates, and moves it back.
  4. The <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.
  5. 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 185 lines

.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);
}

Sass source 190 lines

@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);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.