CSS 3D Buttons: 16 Examples + the Box-Shadow Trick

Most CSS 3D buttons are one element with a solid shadow underneath. A zero-blur box-shadow forms the edge, and a translate on :active sinks the face into it.

The weak spot is forced colors mode, where box-shadow is forced to none. Below are 16 CSS 3D button examples to try and copy, followed by a guide to the techniques behind them.

By Bogdan Sandu · Updated October 28, 2026

Shadow-edge 3D buttons

One element each, no extra markup. A zero-blur box-shadow is the side of the button, and the press moves the face down by exactly the amount the edge shrinks, so the bottom of the button never moves.

Stacked shadow edge that collapses on press

  • box-shadow edge
  • custom properties
  • translateY on :active

Hover, then press and hold a button.

Two shadow layers: a solid edge and a blurred ground shadow. Edge height and press distance are custom properties, so the small variant only changes two numbers and the edge always shrinks by the press distance. The disabled button sits at the pressed height with a grey face, and forced colors mode gets a real border because box-shadow is dropped there.

View code
.db-stack {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px 16px;
  padding-bottom: 12px;
}
.db-stack__btn {
  --db-edge: 6px;
  --db-press: 4px;
  --db-face: #c8102e;
  --db-side: #7d0a1d;
  padding: 14px 32px;
  border: 0;
  border-radius: 12px;
  background: var(--db-face);
  color: #fff;
  font: inherit;
  font-size: 1.0625rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 var(--db-edge) 0 var(--db-side), 0 calc(var(--db-edge) + 6px) 14px rgba(20, 8, 12, .28);
  -webkit-tap-highlight-color: transparent;
}
.db-stack__btn--sm {
  --db-edge: 4px;
  --db-press: 3px;
  --db-face: #1f2a44;
  --db-side: #0b1222;
  padding: 10px 20px;
  border-radius: 10px;
  font-size: .9375rem;
}
@media (hover: hover) {
  .db-stack__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: 0 calc(var(--db-edge) + 2px) 0 var(--db-side), 0 calc(var(--db-edge) + 10px) 18px rgba(20, 8, 12, .24);
  }
}
.db-stack__btn:active:not(:disabled) {
  transform: translateY(var(--db-press));
  box-shadow: 0 calc(var(--db-edge) - var(--db-press)) 0 var(--db-side), 0 calc(var(--db-edge) - var(--db-press) + 2px) 4px rgba(20, 8, 12, .3);
}
.db-stack__btn:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 4px;
}
.db-stack__btn:disabled {
  transform: translateY(var(--db-press));
  background: #d9dce3;
  color: #4b5161;
  box-shadow: 0 calc(var(--db-edge) - var(--db-press)) 0 #b4b9c4;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-stack__btn {
    transition: transform 250ms cubic-bezier(.3, .7, .4, 1.5), box-shadow 250ms cubic-bezier(.3, .7, .4, 1.5);
  }
  .db-stack__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-stack__btn {
    border: 2px solid ButtonText;
  }
}

Pill with a bottom edge

  • border-radius: 999px
  • solid edge
  • icon nudge

Hover, then press and hold a button.

A full pill radius makes the edge follow the curve, so it shows as a crescent under the ends instead of a flat strip. The edge is a darker shade of the face rather than black, which keeps the yellow version from looking outlined. The arrow travels a few pixels on hover, and only when motion is allowed.

View code
.db-pill {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 22px 14px;
  padding-bottom: 10px;
}
.db-pill__btn {
  --db-side: #b88a00;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 50px;
  padding: 0 26px;
  border: 0;
  border-radius: 999px;
  background: #ffd23f;
  color: #1d1a05;
  font: inherit;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 5px 0 var(--db-side), 0 10px 14px rgba(60, 45, 0, .18);
}
.db-pill__btn--ghost {
  --db-side: #b9c0cf;
  background: #fff;
  color: #1d2433;
}
.db-pill__btn svg {
  flex: none;
}
@media (hover: hover) {
  .db-pill__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: 0 7px 0 var(--db-side), 0 14px 18px rgba(60, 45, 0, .16);
  }
  .db-pill__btn:hover:not(:disabled) svg {
    transform: translateX(3px);
  }
}
.db-pill__btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 1px 0 var(--db-side), 0 2px 4px rgba(60, 45, 0, .2);
}
.db-pill__btn:focus-visible {
  outline: 3px solid #1d1a05;
  outline-offset: 4px;
}
.db-pill__btn:disabled {
  transform: translateY(4px);
  background: #e3e5ea;
  color: #4b5161;
  box-shadow: 0 1px 0 #b9c0cf;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-pill__btn,
  .db-pill__btn svg {
    transition: transform 220ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 220ms ease;
  }
  .db-pill__btn:active:not(:disabled) {
    transition-duration: 40ms;
  }
}
@media (forced-colors: active) {
  .db-pill__btn {
    border: 2px solid ButtonText;
  }
}

Outlined 3D, drawn as a wireframe

  • border + offset layer
  • @property
  • hard-stop hatching

Hover, then press and hold a button.

The depth is a second outlined rectangle behind the face, filled with hard-stop hatching like a technical drawing, plus two short connector lines at the corners. One registered custom property drives the offset, the connector length and the press, so all three animate together. Pressing slides the face diagonally onto the back outline.

View code
@property --db-wire-d {
  syntax: "<length>";
  inherits: true;
  initial-value: 7px;
}
.db-wire {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 26px 24px;
  padding: 4px 12px 14px 0;
}
.db-wire__btn {
  --db-wire-d: 7px;
  position: relative;
  padding: 0;
  border: 0;
  background: transparent;
  color: #13294b;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.db-wire__btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px solid #13294b;
  border-radius: 4px;
  background: repeating-linear-gradient(-45deg, #13294b 0 1.5px, transparent 1.5px 6px);
  transform: translate(var(--db-wire-d), var(--db-wire-d));
}
.db-wire__btn::after,
.db-wire__c {
  content: "";
  position: absolute;
  width: calc(var(--db-wire-d) * 1.4142);
  height: 2px;
  background: #13294b;
  transform: rotate(45deg);
  transform-origin: 0 50%;
}
.db-wire__btn::after {
  top: -1px;
  left: calc(100% - 1px);
}
.db-wire__c {
  bottom: -1px;
  left: -1px;
}
.db-wire__face {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 48px;
  padding: 0 24px;
  border: 2px solid #13294b;
  border-radius: 4px;
  background: #fbf8f1;
}
.db-wire__btn--alt .db-wire__face {
  background: #13294b;
  color: #fbf8f1;
}
@media (hover: hover) {
  .db-wire__btn:hover:not(:disabled) {
    --db-wire-d: 9px;
    transform: translate(-2px, -2px);
  }
}
.db-wire__btn:active:not(:disabled) {
  --db-wire-d: 2px;
  transform: translate(5px, 5px);
}
.db-wire__btn:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 5px;
}
.db-wire__btn:disabled {
  --db-wire-d: 2px;
  transform: translate(5px, 5px);
  color: #4b5568;
  cursor: not-allowed;
}
.db-wire__btn:disabled .db-wire__face {
  border-color: #5b6577;
  background: #eceef2;
  color: #4b5568;
}
@media (prefers-reduced-motion: no-preference) {
  .db-wire__btn {
    transition: transform 200ms cubic-bezier(.3, .7, .4, 1.3), --db-wire-d 200ms cubic-bezier(.3, .7, .4, 1.3);
  }
  .db-wire__btn:active:not(:disabled) {
    transition-duration: 40ms;
  }
}
@media (forced-colors: active) {
  .db-wire__face {
    border-color: ButtonText;
  }
}

Soft clay button

  • inset box-shadow
  • scale on press
  • pastel with dark text

Hover, then press and hold a button.

Clay styling builds the depth inside the button: a light inset shadow top left, a tinted inset shadow bottom right, and a soft drop shadow in the same hue. The press swaps the inset shadows and scales the button down 2 percent, so it squashes instead of sinking. The labels are dark on pastel, which keeps them above 4.5:1.

View code
.db-clay {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 18px;
  padding: 30px 26px;
  border-radius: 30px;
  background: #ffe9dc;
}
.db-clay__btn {
  --db-tint: 74, 40, 140;
  min-height: 56px;
  padding: 0 28px;
  border: 0;
  border-radius: 22px;
  background: #cdb9f4;
  color: #2a1650;
  font: inherit;
  font-size: 1.0625rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: inset 6px 6px 12px rgba(255, 255, 255, .6), inset -6px -8px 12px rgba(var(--db-tint), .26), 0 10px 18px rgba(var(--db-tint), .22);
}
.db-clay__btn--mint {
  --db-tint: 16, 104, 76;
  background: #a9e7d0;
  color: #0f3d2c;
}
@media (hover: hover) {
  .db-clay__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: inset 6px 6px 12px rgba(255, 255, 255, .7), inset -6px -8px 12px rgba(var(--db-tint), .26), 0 14px 22px rgba(var(--db-tint), .24);
  }
}
.db-clay__btn:active:not(:disabled) {
  transform: translateY(2px) scale(.98);
  box-shadow: inset 5px 5px 10px rgba(var(--db-tint), .3), inset -4px -4px 8px rgba(255, 255, 255, .45), 0 3px 6px rgba(var(--db-tint), .2);
}
.db-clay__btn:focus-visible {
  outline: 3px solid #2a1650;
  outline-offset: 4px;
}
.db-clay__btn:disabled {
  background: #e6e1ea;
  color: #4f4a57;
  box-shadow: inset 3px 3px 8px rgba(80, 70, 90, .14);
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-clay__btn {
    transition: transform 260ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 260ms ease;
  }
  .db-clay__btn:active:not(:disabled) {
    transition-duration: 60ms;
  }
}
@media (forced-colors: active) {
  .db-clay__btn {
    border: 2px solid ButtonText;
  }
}

Dark mode 3D buttons

  • edge carries depth
  • inset highlight
  • amber focus ring

web-api on main, build 418 passed

Hover, then press and hold a button.

A black ground shadow disappears on a near-black page, so these buttons rely on the edge and a 1px inset highlight along the top. The muted button gets a face lighter than its panel and an edge darker than its face, which keeps it readable as a raised object. The focus ring is amber because blue rings lose contrast on dark surfaces.

View code
.db-night {
  width: min(100%, 420px);
  padding: 20px 20px 26px;
  border: 1px solid #222a3a;
  border-radius: 16px;
  background: #121826;
}
.db-night__head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 18px;
  color: #c3cad8;
  font: 500 .875rem/1.4 ui-monospace, Consolas, monospace;
}
.db-night__dot {
  flex: none;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #34d399;
}
.db-night__row {
  display: flex;
  flex-wrap: wrap;
  gap: 18px 12px;
}
.db-night__btn {
  --db-side: #173a9a;
  min-height: 46px;
  padding: 0 20px;
  border: 0;
  border-radius: 10px;
  background: #2563eb;
  color: #fff;
  font: inherit;
  font-size: .9375rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28), 0 5px 0 var(--db-side);
}
.db-night__btn--muted {
  --db-side: #20262f;
  background: #3a4252;
  color: #f1f3f7;
}
@media (hover: hover) {
  .db-night__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    background: #1d4ed8;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .32), 0 7px 0 var(--db-side);
  }
  .db-night__btn--muted:hover:not(:disabled) {
    background: #454e60;
  }
}
.db-night__btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18), 0 1px 0 var(--db-side);
}
.db-night__btn:focus-visible {
  outline: 3px solid #fbbf24;
  outline-offset: 4px;
}
.db-night__btn:disabled {
  transform: translateY(4px);
  background: #1e2430;
  color: #a3acbd;
  box-shadow: 0 1px 0 #10141b;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-night__btn {
    transition: transform 240ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 240ms ease, background-color 160ms ease;
  }
  .db-night__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-night__btn {
    border: 2px solid ButtonText;
  }
}

3D buttons modelled on real objects

Keycaps, arcade buttons, game menus and a classic bevelled dialog. Each one borrows its depth cues from the physical thing it copies, so the press has to travel the way that object does.

Sculpted keycaps on a macro pad

  • inset lip
  • two-tone wall
  • aria-keyshortcuts

Press and hold a button.

Each key has three shadow layers: an inset strip at the bottom of the face for the sculpted lip, a solid wall below it and a short dark ground shadow. Keys do not lift on hover like screen buttons do, they only lighten, and the press drops them 4px. The function-key legend is aria-hidden, and aria-keyshortcuts announces it instead.

View code
.db-pad {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 84px));
  gap: 14px 12px;
  padding: 18px 18px 26px;
  border-radius: 18px;
  background: #3b3a36;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, .08), 0 14px 30px rgba(20, 18, 14, .25);
}
.db-pad__key {
  --db-cap: #ece6d8;
  --db-lip: #d6cebd;
  --db-wall: #a59c89;
  position: relative;
  display: flex;
  align-items: flex-end;
  height: 76px;
  padding: 0 10px 13px;
  border: 0;
  border-radius: 10px;
  background: var(--db-cap);
  color: #2b2924;
  font: inherit;
  font-size: .875rem;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
  box-shadow: inset 0 -6px 0 var(--db-lip), inset 0 0 0 1px rgba(0, 0, 0, .06), 0 6px 0 var(--db-wall), 0 8px 10px rgba(0, 0, 0, .35);
}
.db-pad__key--accent {
  --db-cap: #c2410c;
  --db-lip: #a6370a;
  --db-wall: #6e2405;
  color: #fff;
}
.db-pad__leg {
  position: absolute;
  top: 9px;
  left: 10px;
  color: #57524a;
  font: 600 .6875rem/1 ui-monospace, Consolas, monospace;
}
.db-pad__key--accent .db-pad__leg {
  color: #fff;
}
.db-pad__key:hover:not(:disabled) {
  --db-cap: #f5f1e8;
}
.db-pad__key--accent:hover:not(:disabled) {
  --db-cap: #cf4a12;
}
.db-pad__key:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: inset 0 -5px 0 var(--db-lip), inset 0 0 0 1px rgba(0, 0, 0, .06), 0 2px 0 var(--db-wall), 0 3px 4px rgba(0, 0, 0, .35);
}
.db-pad__key:focus-visible {
  outline: 3px solid #fbbf24;
  outline-offset: 3px;
}
.db-pad__key:disabled {
  --db-cap: #cfc9bc;
  transform: translateY(4px);
  color: #4d483f;
  box-shadow: inset 0 -5px 0 #c2bbac, 0 2px 0 var(--db-wall);
  cursor: not-allowed;
}
.db-pad__key:disabled .db-pad__leg {
  color: #4d483f;
}
@media (prefers-reduced-motion: no-preference) {
  .db-pad__key {
    transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease;
  }
  .db-pad__key:active:not(:disabled) {
    transition-duration: 30ms;
  }
}
@media (forced-colors: active) {
  .db-pad__key {
    border: 2px solid ButtonText;
  }
}

Arcade push buttons with a credit counter

  • round plunger in a bezel
  • role="status"
  • aria-disabled

Insert coin

Press Coin, then Start.

Each plunger sits in a dark well with a ring drawn by spread shadows, and its 10px edge sinks to 2px on press, the long travel of a real arcade switch. Start uses aria-disabled instead of the disabled attribute, so it keeps focus when the last credit is spent. The screen is a status region, so each coin and start is announced.

View code
.db-arcade {
  display: grid;
  justify-items: center;
  gap: 22px;
  padding: 22px 26px 30px;
  border: 3px solid #2c2933;
  border-radius: 20px;
  background: #17151c;
}
.db-arcade__screen {
  margin: 0;
  min-width: 200px;
  padding: 9px 14px;
  border-radius: 6px;
  background: #0b0a0e;
  color: #7cf29a;
  font: 700 .875rem/1.2 ui-monospace, Consolas, monospace;
  letter-spacing: .1em;
  text-align: center;
  text-transform: uppercase;
}
.db-arcade__row {
  display: flex;
  gap: 26px;
}
.db-arcade__well {
  display: grid;
  place-items: center;
  width: 112px;
  height: 112px;
  border-radius: 50%;
  background: #0b0a0e;
  box-shadow: inset 0 4px 0 rgba(0, 0, 0, .6), 0 0 0 6px #3a3642, 0 0 0 8px #0b0a0e;
}
.db-arcade__btn {
  --db-face: #d61f45;
  --db-side: #7d0f26;
  width: 84px;
  height: 84px;
  margin-top: -10px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--db-face);
  color: #fff;
  font: inherit;
  font-size: .8125rem;
  font-weight: 800;
  letter-spacing: .08em;
  text-transform: uppercase;
  cursor: pointer;
  box-shadow: inset 0 -6px 0 rgba(0, 0, 0, .18), 0 10px 0 var(--db-side), 0 12px 4px rgba(0, 0, 0, .5);
  -webkit-tap-highlight-color: transparent;
}
.db-arcade__btn--start {
  --db-face: #facc15;
  --db-side: #94740a;
  color: #2a2100;
}
.db-arcade__btn:hover:not(:disabled):not([aria-disabled="true"]) {
  --db-face: #b8173a;
}
.db-arcade__btn--start:hover:not(:disabled):not([aria-disabled="true"]) {
  --db-face: #fcd94a;
}
.db-arcade__btn:active:not(:disabled):not([aria-disabled="true"]) {
  transform: translateY(8px);
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .18), 0 2px 0 var(--db-side), 0 3px 2px rgba(0, 0, 0, .5);
}
.db-arcade__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 5px;
}
.db-arcade__btn:disabled,
.db-arcade__btn[aria-disabled="true"] {
  --db-face: #5f5a66;
  --db-side: #36323c;
  transform: translateY(6px);
  color: #f4f2f7;
  box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .18), 0 4px 0 var(--db-side);
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-arcade__btn {
    transition: transform 160ms cubic-bezier(.3, .7, .4, 1.5), box-shadow 160ms ease, background-color 160ms ease;
  }
  .db-arcade__btn:active:not(:disabled):not([aria-disabled="true"]) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-arcade__btn {
    border: 2px solid ButtonText;
  }
}

Game menu buttons

  • thick outline + edge
  • inset lip band
  • price chip

Press and hold a button.

Game interfaces exaggerate everything: a 3px dark outline, a lighter band along the top, a darker lip inside the bottom and a solid outline-colored edge below. The press closes the edge and shrinks the lip together. The locked level is a real disabled button that sits flat with a padlock icon.

View code
.db-game {
  display: grid;
  gap: 18px;
  width: min(100%, 280px);
  padding: 26px 24px 32px;
  border-radius: 22px;
  background: #24164d;
}
.db-game__btn {
  --db-face: #5ad16b;
  --db-lip: #2f9a40;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 58px;
  padding: 0 18px;
  border: 3px solid #120a2b;
  border-radius: 16px;
  background: var(--db-face);
  color: #0e2a12;
  font: inherit;
  font-size: 1.25rem;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  cursor: pointer;
  box-shadow: inset 0 -7px 0 var(--db-lip), inset 0 4px 0 rgba(255, 255, 255, .4), 0 6px 0 #120a2b;
}
.db-game__btn--shop {
  --db-face: #ffb020;
  --db-lip: #d27d00;
  color: #2b1700;
}
.db-game__price {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 9px;
  border-radius: 999px;
  background: #120a2b;
  color: #ffd36b;
  font-size: .8125rem;
  letter-spacing: .02em;
  text-transform: none;
}
@media (hover: hover) {
  .db-game__btn:hover:not(:disabled) {
    --db-face: #72dd81;
    transform: translateY(-2px);
    box-shadow: inset 0 -7px 0 var(--db-lip), inset 0 4px 0 rgba(255, 255, 255, .45), 0 8px 0 #120a2b;
  }
  .db-game__btn--shop:hover:not(:disabled) {
    --db-face: #ffbf47;
  }
}
.db-game__btn:active:not(:disabled) {
  transform: translateY(5px);
  box-shadow: inset 0 -4px 0 var(--db-lip), inset 0 3px 0 rgba(255, 255, 255, .35), 0 1px 0 #120a2b;
}
.db-game__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 4px;
}
.db-game__btn:disabled {
  --db-face: #8c84a8;
  --db-lip: #766e93;
  transform: translateY(4px);
  color: #16102c;
  box-shadow: inset 0 -4px 0 var(--db-lip), 0 2px 0 #120a2b;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-game__btn {
    transition: transform 200ms cubic-bezier(.3, .7, .4, 1.6), box-shadow 200ms ease, background-color 160ms ease;
  }
  .db-game__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-game__btn {
    border-color: ButtonText;
  }
}

Retro bevel with outset and inset borders

  • border-style: outset
  • inset on :active
  • dotted inner focus

Jotter 95

Save changes to field-notes.txt before closing?

Press and hold a button.

No shadows at all. border-style: outset tells the browser to shade the four sides for a raised look, and :active switches it to inset and nudges the label 1px down and right, the way classic desktop dialogs did. The focus ring is a dotted outline drawn inside the button with a negative outline-offset.

View code
.db-bevel {
  width: min(100%, 330px);
  border: 3px outset #dcdcdc;
  background: #c3c3c3;
  box-shadow: 6px 6px 0 rgba(0, 0, 0, .18);
}
.db-bevel__bar {
  margin: 0;
  padding: 5px 9px;
  background: #00007a;
  color: #fff;
  font-size: .875rem;
  font-weight: 700;
  line-height: 1.4;
}
.db-bevel__body {
  padding: 16px 14px 14px;
}
.db-bevel__msg {
  margin: 0 0 18px;
  color: #111;
  font-size: .9375rem;
  line-height: 1.45;
}
.db-bevel__row {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
}
.db-bevel__btn {
  min-width: 82px;
  min-height: 36px;
  padding: 0 12px;
  border: 3px outset #d4d4d4;
  border-radius: 0;
  background: #c3c3c3;
  color: #000;
  font: inherit;
  font-size: .875rem;
  cursor: pointer;
}
.db-bevel__btn--default {
  box-shadow: 0 0 0 1px #000;
}
.db-bevel__btn:hover:not(:disabled) {
  background: #cdcdcd;
}
.db-bevel__btn:active:not(:disabled) {
  border-style: inset;
  padding: 2px 10px 0 14px;
  background: #bababa;
}
.db-bevel__btn:focus-visible {
  outline: 2px dotted #000;
  outline-offset: -7px;
}
.db-bevel__btn:disabled {
  color: #4f4f4f;
  text-shadow: 1px 1px 0 #ececec;
  cursor: default;
}
@media (forced-colors: active) {
  .db-bevel__btn {
    border-color: ButtonText;
  }
}

3D buttons with real transforms

Here the side faces are actual elements: skewed panels, faces placed with translateZ inside a preserve-3d container, and a button that flips over to ask for confirmation.

Isometric-style block from skewed side faces

  • skewX / skewY
  • @property
  • translate on :active

Hover, then press and hold a block.

The two pseudo-elements are the bottom and right sides of the block: each starts as a strip and gets skewed 45 degrees from its top-left corner, so they meet along a clean diagonal. One registered length sets their depth, and the press moves the face diagonally by the same amount the sides lose. Strictly this is an oblique projection, since the face stays flat to the viewer and the label stays readable.

View code
@property --db-iso-d {
  syntax: "<length>";
  inherits: true;
  initial-value: 12px;
}
.db-iso {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 30px 34px;
  padding: 0 14px 16px 0;
}
.db-iso__btn {
  --db-iso-d: 12px;
  --db-s1: #d9a400;
  --db-s2: #a67c00;
  position: relative;
  min-height: 52px;
  padding: 0 26px;
  border: 0;
  border-radius: 0;
  background: #ffcf3f;
  color: #2b2100;
  font: inherit;
  font-size: 1.0625rem;
  font-weight: 800;
  cursor: pointer;
}
.db-iso__btn--teal {
  --db-s1: #0b5952;
  --db-s2: #073b36;
  background: #0f766e;
  color: #fff;
}
.db-iso__btn::before,
.db-iso__btn::after {
  content: "";
  position: absolute;
  transform-origin: 0 0;
}
.db-iso__btn::before {
  top: 100%;
  left: 0;
  width: 100%;
  height: var(--db-iso-d);
  background: var(--db-s1);
  transform: skewX(45deg);
}
.db-iso__btn::after {
  top: 0;
  left: 100%;
  width: var(--db-iso-d);
  height: 100%;
  background: var(--db-s2);
  transform: skewY(45deg);
}
@media (hover: hover) {
  .db-iso__btn:hover:not(:disabled) {
    --db-iso-d: 15px;
    transform: translate(-3px, -3px);
  }
}
.db-iso__btn:active:not(:disabled) {
  --db-iso-d: 3px;
  transform: translate(9px, 9px);
}
.db-iso__btn:focus-visible {
  outline: 3px solid #1e1b4b;
  outline-offset: 3px;
}
.db-iso__btn:disabled {
  --db-iso-d: 3px;
  --db-s1: #b9bec9;
  --db-s2: #9aa0ad;
  transform: translate(9px, 9px);
  background: #dcdfe5;
  color: #4b5161;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-iso__btn {
    transition: transform 220ms cubic-bezier(.3, .7, .4, 1.3), --db-iso-d 220ms cubic-bezier(.3, .7, .4, 1.3);
  }
  .db-iso__btn:active:not(:disabled) {
    transition-duration: 40ms;
  }
}
@media (forced-colors: active) {
  .db-iso__btn {
    border: 2px solid ButtonText;
  }
}

Slab button with preserve-3d side faces

  • perspective
  • transform-style: preserve-3d
  • translateZ faces

Hover, then press and hold the slab.

The button is a transparent 3D container holding three real faces: the front pushed forward with translateZ, and a bottom and a right face folded back 90 degrees from its edges. Hover turns the slab toward you, and the press moves the front back while the side faces shorten, so the back of the slab stays put. Nothing on the button uses opacity, filter or overflow, because any of those would flatten the faces.

View code
.db-slab {
  display: grid;
  justify-items: center;
  gap: 42px;
  padding: 12px 28px 26px 0;
  perspective: 700px;
}
.db-slab__btn {
  --db-front: #6d28d9;
  --db-bottom: #3b1380;
  --db-side: #4c1d95;
  position: relative;
  display: block;
  width: 210px;
  height: 56px;
  padding: 0;
  border: 0;
  background: transparent;
  color: #fff;
  font: inherit;
  cursor: pointer;
  transform-style: preserve-3d;
  transform: rotateX(30deg) rotateY(-24deg);
  -webkit-tap-highlight-color: transparent;
}
.db-slab__front,
.db-slab__bottom,
.db-slab__side {
  position: absolute;
}
.db-slab__front {
  inset: 0;
  display: grid;
  place-items: center;
  background: var(--db-front);
  font-size: 1.0625rem;
  font-weight: 800;
  transform: translateZ(14px);
}
.db-slab__bottom {
  top: 100%;
  left: 0;
  width: 100%;
  height: 28px;
  background: var(--db-bottom);
  transform-origin: 50% 0;
  transform: translateZ(14px) rotateX(-90deg);
}
.db-slab__side {
  top: 0;
  left: 100%;
  width: 28px;
  height: 100%;
  background: var(--db-side);
  transform-origin: 0 50%;
  transform: translateZ(14px) rotateY(90deg);
}
@media (hover: hover) {
  .db-slab__btn:hover:not(:disabled) {
    --db-front: #7c3aed;
    transform: rotateX(18deg) rotateY(-12deg);
  }
}
.db-slab__btn:active:not(:disabled) .db-slab__front {
  transform: translateZ(2px);
}
.db-slab__btn:active:not(:disabled) .db-slab__bottom {
  transform: translateZ(2px) rotateX(-90deg) scaleY(.57);
}
.db-slab__btn:active:not(:disabled) .db-slab__side {
  transform: translateZ(2px) rotateY(90deg) scaleX(.57);
}
.db-slab__btn:focus-visible {
  outline: 3px solid #111827;
  outline-offset: 6px;
}
.db-slab__btn:disabled {
  --db-front: #dcdfe5;
  --db-bottom: #a9afbb;
  --db-side: #bcc1cb;
  color: #4b5161;
  cursor: not-allowed;
}
.db-slab__btn:disabled .db-slab__front {
  transform: translateZ(2px);
}
.db-slab__btn:disabled .db-slab__bottom {
  transform: translateZ(2px) rotateX(-90deg) scaleY(.57);
}
.db-slab__btn:disabled .db-slab__side {
  transform: translateZ(2px) rotateY(90deg) scaleX(.57);
}
@media (prefers-reduced-motion: no-preference) {
  .db-slab__btn,
  .db-slab__front,
  .db-slab__bottom,
  .db-slab__side {
    transition: transform 260ms cubic-bezier(.3, .7, .4, 1.2), background-color 160ms ease;
  }
  .db-slab__btn:active:not(:disabled) .db-slab__front,
  .db-slab__btn:active:not(:disabled) .db-slab__bottom,
  .db-slab__btn:active:not(:disabled) .db-slab__side {
    transition-duration: 40ms;
  }
}
@media (forced-colors: active) {
  .db-slab__front {
    border: 2px solid ButtonText;
  }
}

Flip-to-confirm delete button

  • rotateX(180deg)
  • backface-visibility
  • inert

Removes Harbor Lab and its 14 boards.

Click Delete workspace. Escape or Cancel flips it back.

The danger button and its confirmation are the two faces of one card, and the card turns over on the X axis like a flap. The hidden face is inert, so its buttons are never in the tab order, and focus moves to Cancel, the safe choice, when the card flips. After a delete, the front face turns into an Undo button.

View code
.db-flip {
  display: grid;
  justify-items: center;
  gap: 10px;
  width: min(100%, 320px);
  perspective: 800px;
}
.db-flip__card {
  position: relative;
  width: 100%;
  height: 66px;
  transform-style: preserve-3d;
}
.db-flip[data-flipped="true"] .db-flip__card {
  transform: rotateX(180deg);
}
.db-flip__face {
  position: absolute;
  inset: 0 0 8px;
  display: grid;
  gap: 10px;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
.db-flip__face--back {
  grid-template-columns: 1fr 1fr;
  transform: rotateX(180deg);
}
.db-flip__btn {
  --db-side: #c3c9d6;
  height: 100%;
  padding: 0 14px;
  border: 2px solid #c3c9d6;
  border-radius: 12px;
  background: #fff;
  color: #1f2430;
  font: inherit;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 6px 0 var(--db-side);
}
.db-flip__btn--danger {
  --db-side: #7f1212;
  border-color: #b91c1c;
  background: #b91c1c;
  color: #fff;
}
.db-flip[data-deleted="true"] .db-flip__open {
  --db-side: #c3c9d6;
  border-color: #c3c9d6;
  background: #fff;
  color: #1f2430;
}
@media (hover: hover) {
  .db-flip__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: 0 8px 0 var(--db-side);
  }
}
.db-flip__btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 2px 0 var(--db-side);
}
.db-flip__btn:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
.db-flip__btn:disabled {
  border-color: #d9dce3;
  background: #d9dce3;
  color: #4b5161;
  box-shadow: 0 2px 0 #b4b9c4;
  transform: translateY(4px);
  cursor: not-allowed;
}
.db-flip__desc,
.db-flip__status {
  margin: 0;
  color: #3d4454;
  font-size: .875rem;
  text-align: center;
}
.db-flip__status {
  min-height: 1.4em;
  color: #1f2430;
  font-weight: 700;
}
@media (prefers-reduced-motion: no-preference) {
  .db-flip__card {
    transition: transform 520ms cubic-bezier(.3, .7, .3, 1.15);
  }
  .db-flip__btn {
    transition: transform 200ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 200ms ease;
  }
  .db-flip__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-flip__btn {
    border-color: ButtonText;
  }
}

3D button states, groups and icons

The parts most 3D button demos skip: a toggle that stays pressed, icon-only buttons with real names, a joined group with one continuous edge, and a disabled state that reads as flat.

Toggle buttons that stay pressed

  • aria-pressed
  • attribute selector
  • inset shadow

Every press should land somewhere.

Toggle Italic and Underline.

A 3D toggle is the one case where the pressed look should last: the [aria-pressed="true"] rule keeps the button down, swaps the edge for an inner shadow and tints the face. Because the styling hangs on the ARIA attribute, the look and the announced state cannot drift apart. The preview line below shows what each toggle does.

View code
.db-fmt {
  display: grid;
  justify-items: center;
  gap: 22px;
}
.db-fmt__bar {
  display: flex;
  gap: 8px;
  padding: 8px 8px 12px;
  border-radius: 14px;
  background: #e1e5ef;
}
.db-fmt__btn {
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid #b9c0cf;
  border-radius: 9px;
  background: #fff;
  color: #1f2430;
  font: inherit;
  font-size: .9375rem;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 4px 0 #b9c0cf;
}
.db-fmt__btn[data-style="bold"] {
  font-weight: 800;
}
.db-fmt__btn[data-style="italic"] {
  font-style: italic;
}
.db-fmt__btn[data-style="underline"] {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.db-fmt__btn:hover:not(:disabled) {
  background: #f5f7fb;
}
.db-fmt__btn:active:not(:disabled),
.db-fmt__btn[aria-pressed="true"] {
  transform: translateY(3px);
  box-shadow: 0 1px 0 #b9c0cf;
}
.db-fmt__btn[aria-pressed="true"] {
  border-color: #7d93dd;
  background: #dbe4ff;
  color: #1d3a8a;
  box-shadow: inset 0 2px 4px rgba(29, 58, 138, .22), 0 1px 0 #7d93dd;
}
.db-fmt__btn:focus-visible {
  outline: 3px solid #1d3a8a;
  outline-offset: 3px;
}
.db-fmt__btn:disabled {
  transform: translateY(3px);
  background: #e9ebf0;
  color: #4b5161;
  box-shadow: none;
  cursor: not-allowed;
}
.db-fmt__preview {
  max-width: 300px;
  margin: 0;
  color: #1f2430;
  font-family: "Fraunces", serif;
  font-size: 1.375rem;
  line-height: 1.3;
  text-align: center;
}
.db-fmt__preview--bold {
  font-weight: 700;
}
.db-fmt__preview--italic {
  font-style: italic;
}
.db-fmt__preview--underline {
  text-decoration: underline;
  text-underline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .db-fmt__btn {
    transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease;
  }
}
@media (forced-colors: active) {
  .db-fmt__btn[aria-pressed="true"] {
    border: 3px solid Highlight;
  }
}

3D icon buttons with accessible names

  • aria-label
  • aria-hidden SVG
  • aria-pressed play

Harbor LightsLow Tide Static, track 3 of 9

Press play.

Icon-only buttons still need a name, so each one carries an aria-label and its SVG is aria-hidden and unfocusable. The play button keeps the constant name Play and reports its state with aria-pressed, while the icon swaps to pause. Every target is at least 48px, well above the 24px minimum in WCAG 2.2.

View code
.db-media {
  display: grid;
  justify-items: center;
  gap: 20px;
  width: min(100%, 340px);
  padding: 22px 16px 28px;
  border-radius: 22px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(15, 61, 54, .12);
}
.db-media__title {
  display: grid;
  gap: 4px;
  margin: 0;
  color: #0f2b27;
  font-family: "Fraunces", serif;
  font-size: 1.25rem;
  font-weight: 600;
  text-align: center;
}
.db-media__title span {
  color: #4a5f5b;
  font-family: inherit;
  font-size: .8125rem;
  font-weight: 500;
}
.db-media__row {
  display: flex;
  align-items: center;
  gap: 12px;
}
.db-media__btn {
  --db-face: #e3efec;
  --db-side: #93b3ac;
  display: grid;
  place-items: center;
  width: 50px;
  height: 50px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--db-face);
  color: #0f3d36;
  cursor: pointer;
  box-shadow: 0 5px 0 var(--db-side);
  -webkit-tap-highlight-color: transparent;
}
.db-media__btn--play {
  --db-face: #0f766e;
  --db-side: #07433e;
  width: 68px;
  height: 68px;
  color: #fff;
  box-shadow: 0 6px 0 var(--db-side);
}
.db-media__i-pause,
.db-media__btn--play[aria-pressed="true"] .db-media__i-play {
  display: none;
}
.db-media__btn--play[aria-pressed="true"] .db-media__i-pause {
  display: block;
}
@media (hover: hover) {
  .db-media__btn:hover:not(:disabled) {
    --db-face: #d3e7e2;
    transform: translateY(-2px);
    box-shadow: 0 7px 0 var(--db-side);
  }
  .db-media__btn--play:hover:not(:disabled) {
    --db-face: #0b5f58;
    box-shadow: 0 8px 0 var(--db-side);
  }
}
.db-media__btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 1px 0 var(--db-side);
}
.db-media__btn:focus-visible {
  outline: 3px solid #0f3d36;
  outline-offset: 3px;
}
.db-media__btn:disabled {
  --db-face: #eceeef;
  transform: translateY(4px);
  color: #5d6466;
  box-shadow: 0 1px 0 #c5cacc;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .db-media__btn {
    transition: transform 220ms cubic-bezier(.3, .7, .4, 1.5), box-shadow 220ms ease, background-color 160ms ease;
  }
  .db-media__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-media__btn {
    border: 2px solid ButtonText;
  }
}

3D button group with a shared edge

  • role="group"
  • joined edges
  • aria-disabled at the limits
Tickets
2

Add and remove tickets.

The stepper is three joined pieces whose edges line up into one continuous ledge, and each button sinks on its own while the count stays raised. At the limits the button becomes aria-disabled instead of disabled, so keyboard focus is not dropped mid-tap. The count is an output with aria-live, and the reserve button below repeats it in its label.

View code
.db-qty {
  display: grid;
  justify-items: center;
  gap: 14px;
}
.db-qty__label {
  color: #0f1f3d;
  font-size: .875rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.db-qty__group {
  display: grid;
  grid-template-columns: 58px 66px 58px;
}
.db-qty__btn,
.db-qty__value {
  display: grid;
  place-items: center;
  height: 54px;
  border: 2px solid #0f1f3d;
  background: #fff;
  color: #0f1f3d;
  font: inherit;
  font-size: 1.5rem;
  font-weight: 700;
  box-shadow: 0 6px 0 #0f1f3d;
}
.db-qty__btn {
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.db-qty__dec {
  border-radius: 14px 0 0 14px;
}
.db-qty__inc {
  border-radius: 0 14px 14px 0;
}
.db-qty__value {
  border-right: 0;
  border-left: 0;
  background: #fff4dc;
  font-size: 1.25rem;
  font-variant-numeric: tabular-nums;
}
.db-qty__btn:hover:not(:disabled):not([aria-disabled="true"]) {
  background: #eef2fb;
}
.db-qty__btn:active:not(:disabled):not([aria-disabled="true"]) {
  transform: translateY(5px);
  box-shadow: 0 1px 0 #0f1f3d;
}
.db-qty__btn:focus-visible,
.db-qty__buy:focus-visible {
  position: relative;
  z-index: 1;
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
.db-qty__btn:disabled,
.db-qty__btn[aria-disabled="true"] {
  transform: translateY(5px);
  background: #e4e7ee;
  color: #4b5161;
  box-shadow: 0 1px 0 #0f1f3d;
  cursor: not-allowed;
}
.db-qty__buy {
  min-height: 50px;
  margin-top: 6px;
  padding: 0 26px;
  border: 0;
  border-radius: 12px;
  background: #0f1f3d;
  color: #fff;
  font: inherit;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 5px 0 #c2410c;
}
@media (hover: hover) {
  .db-qty__buy:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: 0 7px 0 #c2410c;
  }
}
.db-qty__buy:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 1px 0 #c2410c;
}
.db-qty__buy:disabled {
  background: #d9dce3;
  color: #4b5161;
  box-shadow: 0 1px 0 #b4b9c4;
  transform: translateY(4px);
  cursor: not-allowed;
}
.db-qty__status {
  min-height: 1.4em;
  margin: 0;
  color: #0f1f3d;
  font-size: .875rem;
  font-weight: 600;
}
@media (prefers-reduced-motion: no-preference) {
  .db-qty__btn,
  .db-qty__buy {
    transition: transform 180ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 180ms ease, background-color 120ms ease;
  }
  .db-qty__btn:active:not(:disabled):not([aria-disabled="true"]),
  .db-qty__buy:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-qty__btn,
  .db-qty__value,
  .db-qty__buy {
    border: 2px solid ButtonText;
  }
}

Disabled 3D state that sits flat

  • :disabled
  • pressed-height resting state
  • aria-describedby

Kayak rental, 2 hours$48.00

Tick the box above to enable payment.

Tick the box to enable the button.

A disabled 3D button should not look pressable, so it rests at the pressed height with a 2px edge and a grey face, and the hover and press rules are scoped with :not(:disabled). Disabled controls are exempt from WCAG contrast rules, but the label here still clears 4.5:1. The reason the button is disabled sits right under it and is linked with aria-describedby.

View code
.db-gate {
  display: grid;
  gap: 16px;
  width: min(100%, 320px);
  padding: 22px 20px 20px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 16px 36px rgba(22, 27, 45, .1);
}
.db-gate__sum {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 0;
  padding-bottom: 14px;
  border-bottom: 1px solid #e3e6ee;
  color: #1f2430;
  font-size: .9375rem;
}
.db-gate__check {
  display: flex;
  align-items: center;
  gap: 10px;
  color: #1f2430;
  font-size: .9375rem;
  cursor: pointer;
}
.db-gate__box {
  flex: none;
  width: 22px;
  height: 22px;
  margin: 0;
  accent-color: #4338ca;
}
.db-gate__box:focus-visible {
  outline: 3px solid #4338ca;
  outline-offset: 2px;
}
.db-gate__btn {
  min-height: 52px;
  border: 0;
  border-radius: 12px;
  background: #4338ca;
  color: #fff;
  font: inherit;
  font-size: 1.0625rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 6px 0 #2a2190;
}
@media (hover: hover) {
  .db-gate__btn:hover:not(:disabled) {
    transform: translateY(-2px);
    background: #4f46e5;
    box-shadow: 0 8px 0 #2a2190;
  }
}
.db-gate__btn:active:not(:disabled) {
  transform: translateY(4px);
  box-shadow: 0 2px 0 #2a2190;
}
.db-gate__btn:focus-visible {
  outline: 3px solid #1f2430;
  outline-offset: 4px;
}
.db-gate__btn:disabled {
  transform: translateY(4px);
  background: #e2e4ea;
  color: #4b5161;
  box-shadow: 0 2px 0 #bfc4ce;
  cursor: not-allowed;
}
.db-gate__why {
  margin: 0;
  color: #4b5161;
  font-size: .8125rem;
  line-height: 1.4;
}
@media (prefers-reduced-motion: no-preference) {
  .db-gate__btn {
    transition: transform 240ms cubic-bezier(.3, .7, .4, 1.4), box-shadow 240ms ease, background-color 200ms ease, color 200ms ease;
  }
  .db-gate__btn:active:not(:disabled) {
    transition-duration: 34ms;
  }
}
@media (forced-colors: active) {
  .db-gate__btn {
    border: 2px solid ButtonText;
  }
}

How do you build a CSS 3D button with box-shadow?

The fast version needs one element and two rules. The shadow is the bottom edge, and the :active state moves the button down onto it.

No JavaScript is involved. The face moves down while the edge stays where it is, and that reads as a physical key.

  1. Start with a plain button element. Remove the default border, then set padding (14px 36px), a 12px border-radius and a saturated background.
  2. Add the edge as a box-shadow with 0 blur and a 6px vertical offset, in a darker shade of the face color.
  3. Add the ground shadow as a second, blurred layer (0 12px 16px at 30% black) so the button separates from the page.
  4. Write the pressed state. On :active, translate the button down 4px and cut the edge offset from 6px to 2px.
  5. Transition transform and box-shadow over about 80ms so the press doesn’t snap.
.btn3d {
  padding: 14px 36px;
  border: none;
  border-radius: 12px;
  background: #e8344e;
  color: #fff;
  font-size: 1.125rem;
  cursor: pointer;
  box-shadow: 0 6px 0 #9c1f34, 0 12px 16px rgba(0, 0, 0, 0.3);
  transition: transform 80ms, box-shadow 80ms;
}

.btn3d:active {
  transform: translateY(4px);
  box-shadow: 0 2px 0 #9c1f34, 0 4px 6px rgba(0, 0, 0, 0.3);
}

What sells the effect is shrinking the edge by exactly the translate distance. A 6px edge minus a 4px press leaves 2px, so the bottom of the shadow stays on the same pixel row while the face sinks into it.

The catch is cost. Josh W. Comeau’s 3D button tutorial (last updated May 2026) flags box-shadow and border as expensive properties to animate. The layered build later in this article moves elements instead for exactly that reason.

When I’m tuning the edge and ground layers, a box shadow generator saves a lot of reloading, since every offset change shows up live.

Comparing the depth techniques for a CSS 3D button

For most interfaces, stacked box-shadow is enough. Buttons that animate a lot do better with layered elements, and true 3D transforms only earn their complexity when the button actually rotates. Inset and outset borders are the simplest to write and the hardest to control.

TechniqueHow depth is builtPress behaviorMain limit
Inset and outset bordersBrowser-shaded border sidesSwap outset for insetLittle color control
Stacked box-shadowSolid zero-blur shadow edgeTranslate plus smaller shadowAnimates a costly property
Layered elementsShadow, edge and front spansTransform on front and shadowExtra markup, absolute layout
Perspective and preserve-3dFaces placed in real 3D spacerotateX or translateZFlattening properties break it

The deciding question is whether the button ever turns or flips. If it does, use true 3D. If it only animates on hover and press, layers hold up better, and for everything else a shadow edge is plenty.

On a primary call-to-action button I’d take the shadow edge over the fancier options nearly every time. It reads as depth at a glance. A rotating button adds a stacking context and flattening rules and doesn’t get any clearer for it.

Inset and outset borders

One declaration swaps a raised look for a pressed one, and the browser picks the shades automatically, as thesitewizard.com describes. Coloring the four borders by hand (light on top and left, dark on bottom and right) gives more control.

It needs no extra markup, no shadows and no transforms. The downside is that the browser decides how the sides are shaded, and the result looks dated in most modern interfaces.

Stacked box-shadow

It’s one element, two rules and a few minutes of work.

The press transition animates box-shadow, which Comeau’s tutorial ranks as costly. On a single button nobody will notice. Across a dense grid of animated buttons it adds up.

Layered elements

Because only transforms move, animation stays cheap. The shadow gets its own blur, independent of the face, and gradient edges make rounded corners look lit.

The price is extra markup (three spans) and absolute positioning on the other layers, while the front layer has to stay in flow to size the button.

Perspective and preserve-3d

This is the only option here with real depth that changes with the viewing angle, which is what you need for flips, tilts and cube-style reveals. It also brings flattening rules and a new stacking context (both covered in the true 3D section below), and a rotating button is harder to read at rest.

How to build a layered CSS 3D button

The layered version wraps three spans in one button element, called shadow, edge and front, and animates only the front and the shadow with transform. Josh W. Comeau’s tutorial, last updated May 2026, is the clearest published version of this structure.

What each layer does

The shadow layer is absolutely positioned at full size, filled with 25% black, softened with blur(4px) and nudged down with translateY.

The edge sits in the same spot, also absolutely positioned at full size, but uses a linear gradient that runs dark, base color, dark. That makes the rounded corners look like they catch less light.

The front is relatively positioned and holds the padding and text. It’s the only in-flow child, which is what gives the button its width and height.

DOM order handles the stacking, so no z-index is needed. Every layer shares the same 12px border-radius.

Offsets by state

StateFront layerShadow layer
ResttranslateY(-4px)translateY(2px)
HovertranslateY(-6px)translateY(4px)
PressedtranslateY(-2px)translateY(1px)

The shadow travels opposite to the front, so the gap between them widens on hover and closes on press. The pressed front stops 2px above the edge instead of 0, which keeps the 3D illusion alive mid-click.

If the button never animates, skip this build. All that extra markup buys you nothing.

Using perspective and rotateX for a true 3D button

True 3D buttons place their faces in actual 3D space. Perspective goes on the parent, transform-style: preserve-3d on the container, translateZ separates the faces and rotateX turns them. I’d reserve all of it for buttons that flip, tilt or reveal a back face.

How perspective length changes the look

The perspective property sets the distance between the viewer and the z=0 plane, so a large value gives a mild effect and a small value a strong one (MDN Web Docs, page updated April 2026). Values under 1px are clamped to 1px.

Any value other than none also creates a stacking context and turns the element into the containing block for fixed-position descendants. The property has been available across browsers since September 2015 (Baseline widely available), and the guide to CSS perspective values goes deeper on tuning the length.

Where preserve-3d breaks

The transform-style property isn’t inherited, so every non-leaf descendant that holds 3D children needs it too.

Even then, MDN’s transform-style reference (updated July 2026) lists properties that force a flat result despite preserve-3d, including:

  • opacity below 1
  • overflow other than visible or clip
  • filter other than none
  • clip-path, mask-image or mix-blend-mode other than their defaults

That list catches a popular hover trick. Comeau’s layered button brightens with filter on the button itself, so copying that onto a preserve-3d container flattens the faces.

A flip button from three faces

hongkiat’s flip button tutorial uses a wrapper and a container with three face divs inside. There’s a front face, a back face and a middle face that fills the depth between them.

translateZ pushes the front and back faces apart along the z-axis, and backface-visibility set to hidden stops the reverse side of the front face from showing through. A rotateX on the container, triggered on hover, does the flip.

There’s one old caveat. Older tutorials reported jagged edges on 3D-transformed shapes in Firefox. Treat that note as dated and test current Firefox before shipping a rotated button.

Setting the hover, active and release states

The press should get the fastest transition and the return to rest the slowest, with the hover somewhere in between and a bit springy. That ordering is what makes the button read as a physical object instead of a fading rectangle.

How each state should behave

On hover, the front rises a few pixels and the button brightens. On press, the front drops toward the edge almost instantly. Release springs it back up with a small overshoot, and once the pointer leaves, it sinks back to rest slowly.

Treat each of these as its own micro-interaction with separate timing. One blanket transition flattens all four into the same motion.

Timing and easing values

ActionDurationEasing
Press34msBrowser default
Hover and release250mscubic-bezier(.3, .7, .4, 1.5)
Leave600mscubic-bezier(.3, .7, .4, 1)

Josh W. Comeau’s tutorial (last updated May 2026) sets the press at 34ms, roughly 2 frames at 60fps, because that is how quickly people push real buttons.

There’s a catch. Hover and release share a rule, so the rise-up and the snap-back after a click cannot get separate curves in pure CSS. Splitting them takes JavaScript.

A 2025 update note in the same tutorial adds that the linear() timing function can model actual spring physics, which Bezier curves only approximate.

Making a CSS 3D button accessible

The raised look turns into a barrier quickly when keyboard users can’t see focus, when the element isn’t a real button, when contrast and size fall short, or when the motion can’t be turned off.

Focus rings on a raised button

Comeau’s build keeps a 4px outline-offset and hides the ring only for pointer users with :focus:not(:focus-visible), so keyboard focus stays visible.

The offset pushes the ring off the button, so W3C’s Understanding document for SC 1.4.11 judges it against the page background, not the red face. In W3C’s examples a yellow ring on white fails and a green ring passes.

Removing the outline outright is a documented failure of success criteria 1.4.11, 2.4.7 and 2.4.13 (technique F78).

Button element or styled anchor

A button element brings the button role plus Enter and Space activation by default. An anchor activates on Enter only. Forced colors mode adds another reason to stick with native elements, since browsers pick system colors from native semantics and not from added roles (MDN Web Docs, 2026).

Use an anchor when the control goes to another URL, and a button for anything that performs an action.

Adding ARIA roles to an anchor or a div gives it the label without the behavior, and MDN notes that role button on a div will not pull ButtonText colors in forced colors mode.

Contrast, target size and reduced motion

A few numbers worth keeping nearby:

  • WCAG 2.2 SC 1.4.11 (Level AA) asks for at least 3:1 contrast on the visual information that identifies a control and its states.
  • SC 2.5.8 (Level AA) sets a minimum target size of 24 by 24 CSS pixels.
  • A 20 by 20 CSS pixel target passes only with 4 CSS pixels of spacing between targets, and fails with no gap (W3C example).
  • prefers-reduced-motion has been available across browsers since January 2020 and Baseline widely available since July 2022 (MDN Web Docs).

Run the face and edge colors through a color contrast check against the page background. Hover raises need no 3:1 of their own, but they must not make the button lose contrast.

The reduce value means the user wants motion removed, reduced or replaced, and MDN names scaling and panning of large objects as vestibular triggers. For this button, drop the transition and keep a cue that does not move, such as the brightness change.

@media (prefers-reduced-motion: reduce) {
  .btn3d,
  .front {
    transition: none;
  }
}

Does animating a CSS 3D button hurt performance?

On one button, no. On a grid of buttons animating blurred shadows, yes, because the browser repaints those frames while transform and opacity changes stay on the compositing stage. Default to the cheap properties and measure before adding anything else.

Cheap and costly properties

web.dev’s animation guide (last updated October 2020) demos the gap. Moving a box with top and left dropped 50% of frames, and the same move with transform dropped 1%.

The demo animates top and left, not box-shadow, so read the percentages as the shape of the gap and not as a shadow benchmark.

Transform and opacity are the cheap ones. Anything that triggers layout or paint costs more, and anything with a blur, a shadow included, paints slower than a flat box.

Where the sources disagree on will-change

Comeau’s final code declares will-change: transform on both moving layers from the start.

MDN and web.dev treat it as a last resort. MDN (page updated October 2026) warns that a stylesheet declaration keeps the browser’s rendering hints in memory far longer than needed, and that overuse can slow the page down.

The gap comes from scale. Comeau tunes one showpiece button, while MDN’s warning targets many elements carrying the hint at once.

Leave it out until DevTools shows dropped frames. If you add it, put it only on the moving layers.

Checking it in DevTools

  1. Record a hover and a press in the Chrome DevTools Performance panel, then read the Summary tab. A large Rendering value points to layout and style work, and a large Painting value points to repaint work.
  2. Turn on Paint flashing in the Rendering tab and press the button. Flashes outside the button mean extra repainting.
  3. Enable the FPS meter (Frame Rendering Stats in the Rendering tab) and watch the dropped-frames percentage while the animation runs.

Where CSS 3D buttons break or do not fit

Forced colors mode and touch screens are where these buttons break, and minimal interfaces are where they look wrong. They suit a few big actions, not every button on a page.

Forced colors mode

Windows High Contrast is the usual example. Under forced colors, browsers force box-shadow and text-shadow to none, and background images that are not url-based go too, which takes gradients with them (MDN Web Docs, April 2026). A shadow-based button loses its depth, and in the layered build the edge gradient disappears as well.

@media (forced-colors: active) {
  .btn3d {
    border: 2px solid ButtonText;
  }
}

MDN’s own example does exactly this for a shadow-based button, and its guidance is to use forced-colors for small fixes, not a separate design.

The media feature has been available across browsers since September 2022 and Baseline widely available since March 2025, so the fix is safe to ship.

Touch screens and press feedback

MDN’s hover media feature reports none for devices that cannot hover or only emulate it on a long tap, so gate the raise behind @media (hover: hover).

Mobile browsers also flash a grey tap rectangle, which Comeau removes with -webkit-tap-highlight-color: transparent. On iOS, a long press can try to select the label after a second, and user-select: none stops it, though Comeau warns that properties like this should be used extremely rarely.

Finish with a real cross-browser compatibility pass. Comeau points to MDN’s notes on how browsers differ on whether a click focuses a button, and Safari needs Option+Tab to reach one.

Interfaces where it does not fit

Comeau calls his version ostentatious and says he would not use it on a GDPR cookie-consent banner. His rule is to keep it for grand, exciting actions.

Dense dashboards and forms with many actions get noise instead of clarity from raised buttons, and a page with ten of them has no primary action. A minimalist design system is the clearest case where a flat button wins.

CSS 3D Button FAQ

How do I style a disabled CSS 3D button?

Flatten it. Scope the hover and press rules with :not(:disabled) so they never fire, then remove the lift and desaturate the face.

WCAG 2.2 SC 1.4.11 exempts inactive controls from the 3:1 contrast rule (W3C), but the label should stay readable.

Can I build a CSS 3D button in Tailwind CSS?

Yes. An arbitrary value such as shadow-[0_6px_0_#9c1f34] sets the solid edge, and active:translate-y-1 moves the button down 0.25rem on press.

Add a transition utility, then shrink the edge in an active: shadow variant. More patterns sit in these Tailwind button examples.

Does a Bootstrap button work as a base for this effect?

Yes. Add a custom class with the edge shadow and an :active translate, loaded after Bootstrap’s stylesheet so it wins the cascade.

Test keyboard focus afterward, because Bootstrap draws its focus ring with box-shadow and the new edge can replace it. The Bootstrap button component supplies the color variants.

What separates a CSS 3D button from a neumorphic button?

A neumorphic button extrudes softly out of a background of the same color, using paired light and dark shadows. The 3D version is blunter, with a solid, darker edge under a face that clearly stands apart.

That solid edge keeps the control identifiable against the page, which matters for the 3:1 non-text contrast rule in WCAG 2.2 SC 1.4.11 (W3C).

Which edge and shadow colors work on a dark background?

Lower the lightness of the face color to get the edge, as Josh W. Comeau’s build does (32% against 47%).

On dark pages a black ground shadow barely shows, so let the edge carry the depth.

Where does an icon or gradient go on the front layer?

Place the icon inside the front layer so it travels with the face, and set the gradient as that layer’s background.

Forced colors mode drops gradient background images (MDN Web Docs), so the label must stay readable on a plain background color.

Making a CSS 3D Button Reusable Across Sizes and Colors

Putting edge height, press distance and hue in custom properties makes the button reusable, because every size and color variant can derive its shadow values from those inputs.

Start with edge height, since every other offset depends on it. Press distance comes next and is capped at the edge height. Hue goes last, with the edge lightness shifted down from the face.

A press distance larger than the edge height pushes the shadow above the face, so the cap is a hard limit, not a style choice.

Timing belongs in shared tokens too. A 600ms return to rest against a 34ms press is a ratio of roughly 18 to 1, and a variant that overrides only one of the two will feel broken next to its siblings.

Readability takes a hit, though. A calc() inside box-shadow is harder to scan than a hardcoded value, so a one-off button should keep the plain rule.