Cool CSS Buttons: 17 Designs You Can Copy and Customize

You can make a button feel finished with CSS alone. Motion has no support excuse left either: prefers-reduced-motion has been Baseline widely available since July 2022, so any animated effect can ship a calmer variant.

Below are 17 cool CSS buttons you can try and copy, followed by a guide to the properties, states and accessibility limits behind them.

By Bogdan Sandu ยท Updated October 28, 2026

Button styles with character

Seven visual directions for a single button, from neo-brutalist to a mechanical keycap. Each one ships its focus ring, pressed look and disabled look, so the style holds up beyond the resting state.

Neo-brutalist with a hard shadow

  • offset box-shadow
  • translate on :active
  • :disabled

A thick black border and a shadow with zero blur give the flat, printed look. On press the button moves by exactly the shadow offset while the shadow shrinks to nothing, so it sits down into its own footprint. The disabled version drops the shadow height and the color, and the press rule is switched off for it.

View code
.bt-brutal {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 18px 16px;
  padding: 6px 6px 12px 0;
}
.bt-brutal__btn {
  min-height: 52px;
  padding: 0 24px;
  border: 3px solid #111;
  border-radius: 10px;
  background: #ffd23f;
  color: #111;
  font: inherit;
  font-size: 1rem;
  font-weight: 700;
  box-shadow: 6px 6px 0 #111;
  cursor: pointer;
  transition: background-color .15s ease;
}
.bt-brutal__btn--alt {
  background: #fff;
}
.bt-brutal__btn:hover {
  background: #ffc300;
}
.bt-brutal__btn--alt:hover {
  background: #ffd1dc;
}
.bt-brutal__btn:focus-visible {
  outline: 3px solid #2346ff;
  outline-offset: 5px;
}
.bt-brutal__btn:active {
  transform: translate(6px, 6px);
  box-shadow: 0 0 0 #111;
}
.bt-brutal__btn:disabled {
  border-color: #6b6b6b;
  background: #e4e4e4;
  color: #4a4a4a;
  box-shadow: 3px 3px 0 #6b6b6b;
  cursor: not-allowed;
  transform: none;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-brutal__btn {
    transition: background-color .15s ease, transform .1s ease, box-shadow .1s ease;
  }
}

Pill call to action with an icon disc

  • inline-flex + gap
  • em-sized SVG
  • aria-hidden icon

14 days free. No card needed.

The label sits on the left and the arrow gets its own contrasting disc, which turns a plain pill into a clear next step. The icon is decorative, so it is hidden from assistive tech and the visible text names the button. Asymmetric padding keeps the disc snug to the right edge.

View code
.bt-pill {
  display: grid;
  justify-items: center;
  gap: 14px;
}
.bt-pill__btn {
  display: inline-flex;
  align-items: center;
  gap: 16px;
  min-height: 56px;
  padding: 8px 8px 8px 26px;
  border: 0;
  border-radius: 999px;
  background: #0f3d2e;
  color: #fff;
  font: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bt-pill__icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #c6f36b;
  color: #0f3d2e;
}
.bt-pill__icon svg {
  width: 1.2em;
  height: 1.2em;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-pill__btn:hover {
  background: #17573f;
}
.bt-pill__btn:focus-visible {
  outline: 3px solid #0f3d2e;
  outline-offset: 4px;
}
.bt-pill__btn:active {
  background: #0a2c21;
}
.bt-pill__btn:disabled {
  background: #d5dbd8;
  color: #46514c;
  cursor: not-allowed;
}
.bt-pill__btn:disabled .bt-pill__icon {
  background: #eef1ef;
  color: #46514c;
}
.bt-pill__meta {
  margin: 0;
  color: #4a5560;
  font-size: .9rem;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-pill__btn {
    transition: background-color .2s ease, transform .12s ease;
  }
  .bt-pill__icon {
    transition: transform .25s ease;
  }
  .bt-pill__btn:not(:disabled):hover .bt-pill__icon {
    transform: translateX(3px);
  }
  .bt-pill__btn:not(:disabled):active {
    transform: scale(.97);
  }
}

Soft UI that still passes contrast

  • paired box-shadow
  • inset on :active
  • 3:1 border

Neumorphism with the two usual fixes. The labels are dark enough for well over 4.5:1 on the soft grey, and a thin border at 3:1 marks the edge, because a light and a dark shadow alone do not identify a control. Pressing swaps the outer shadows for inset ones, which reads as the surface pushing in.

View code
.bt-soft {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 20px;
  padding: 36px min(28px, 5vw);
  border-radius: 28px;
  background: #e3e8ef;
}
.bt-soft__btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 52px;
  padding: 0 24px;
  border: 1px solid #737d8d;
  border-radius: 16px;
  background: #e3e8ef;
  color: #1e2a3b;
  font: inherit;
  font-weight: 600;
  box-shadow: 6px 6px 14px #b8c0cc, -6px -6px 14px #fff;
  cursor: pointer;
  transition: color .15s ease;
}
.bt-soft__btn--primary {
  color: #1c46c4;
}
.bt-soft__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-soft__btn:hover {
  color: #0b1a33;
}
.bt-soft__btn--primary:hover {
  color: #12318f;
}
.bt-soft__btn:focus-visible {
  outline: 3px solid #1c46c4;
  outline-offset: 4px;
}
.bt-soft__btn:active {
  box-shadow: inset 4px 4px 10px #b8c0cc, inset -4px -4px 10px #fff;
}
.bt-soft__btn:disabled {
  border-style: dashed;
  color: #556070;
  box-shadow: none;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-soft__btn {
    transition: color .15s ease, box-shadow .2s ease;
  }
}

Glass buttons on a photo

  • backdrop-filter
  • @supports fallback
  • two-tone focus ring
Hikers on a flat granite cliff top high above a long, deep blue fjord

A solid translucent fill does the contrast work and backdrop-filter only adds the frosted blur, so the label stays readable even where the sky behind it is white. Browsers without backdrop-filter get a more opaque fill. The focus ring is a white box-shadow inside a dark outline, which stays visible on both bright cloud and dark water.

View code
.bt-glass {
  position: relative;
  width: min(520px, 100%);
  margin: 0;
  border-radius: 20px;
  overflow: hidden;
}
.bt-glass__img {
  display: block;
  width: 100%;
  height: 300px;
  object-fit: cover;
}
.bt-glass__bar {
  position: absolute;
  inset: auto 0 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 18px;
}
.bt-glass__btn {
  min-height: 48px;
  padding: 0 22px;
  border: 1px solid rgba(255, 255, 255, .5);
  border-radius: 14px;
  background: rgba(10, 20, 36, .62);
  color: #fff;
  font: inherit;
  font-weight: 600;
  -webkit-backdrop-filter: blur(12px) saturate(140%);
  backdrop-filter: blur(12px) saturate(140%);
  cursor: pointer;
  transition: background-color .2s ease;
}
.bt-glass__btn:hover {
  background: rgba(10, 20, 36, .76);
}
.bt-glass__btn:focus-visible {
  outline: 2px solid #0a1424;
  outline-offset: 2px;
  box-shadow: 0 0 0 2px #fff;
}
.bt-glass__btn:active {
  background: rgba(10, 20, 36, .85);
}
.bt-glass__btn:disabled {
  background: rgba(60, 66, 76, .7);
  color: #d4d8de;
  cursor: not-allowed;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .bt-glass__btn {
    background: rgba(10, 20, 36, .84);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .bt-glass__btn {
    transition: background-color .2s ease, transform .12s ease;
  }
  .bt-glass__btn:not(:disabled):active {
    transform: scale(.97);
  }
}

Outlined ghost next to a solid button

  • transparent fill
  • border as the boundary
  • dark surface

Publish 3 changes?

The pricing page and two blog drafts go live for everyone.

The ghost button is the quiet partner of a solid one: a border, a label and no fill. On a dark surface the border and text both need real contrast, so the outline uses a light periwinkle rather than a dim grey. Hover and press tint the inside with the border color at low opacity.

View code
.bt-ghost {
  width: min(480px, 100%);
  padding: 26px min(26px, 5vw);
  border: 1px solid #2a3245;
  border-radius: 18px;
  background: #121828;
  color: #e7ebf5;
}
.bt-ghost__title {
  margin: 0 0 6px;
  font-size: 1.15rem;
  font-weight: 700;
}
.bt-ghost__text {
  margin: 0 0 20px;
  color: #b4bdd0;
}
.bt-ghost__row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.bt-ghost__btn {
  min-height: 46px;
  padding: 0 20px;
  border: 2px solid #9fb2ff;
  border-radius: 12px;
  background: transparent;
  color: #c9d4ff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}
.bt-ghost__btn--solid {
  background: #9fb2ff;
  color: #0b1024;
}
.bt-ghost__btn:hover {
  background: rgba(159, 178, 255, .14);
  color: #e3e9ff;
}
.bt-ghost__btn--solid:hover {
  background: #bccaff;
  color: #0b1024;
}
.bt-ghost__btn:focus-visible {
  outline: 3px solid #ffd166;
  outline-offset: 3px;
}
.bt-ghost__btn:active {
  background: rgba(159, 178, 255, .26);
}
.bt-ghost__btn--solid:active {
  background: #8299f5;
}
.bt-ghost__btn:disabled {
  border-color: #4a5266;
  background: transparent;
  color: #8a93a7;
  cursor: not-allowed;
}

Pixel button with notched corners

  • stacked box-shadow
  • inset bevel
  • monospace

Four zero-blur shadows, one per side, draw a frame that skips the corners, which gives the stepped 8-bit outline without an image. Two inset shadows add the light and dark bevel, and :active swaps them so the button looks pushed in. The dashed focus ring keeps the pixel feel.

View code
.bt-pixel {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 22px;
  padding: 34px min(30px, 6vw);
  background: #2b1d3a;
  border-radius: 6px;
}
.bt-pixel__btn {
  min-height: 52px;
  margin: 4px;
  padding: 0 22px;
  border: 0;
  border-radius: 0;
  background: #ff5d73;
  color: #1a0b1f;
  font: 700 .95rem/1 ui-monospace, Consolas, monospace;
  letter-spacing: .08em;
  text-transform: uppercase;
  box-shadow: 0 -4px 0 #1a0b1f, 0 4px 0 #1a0b1f, -4px 0 0 #1a0b1f, 4px 0 0 #1a0b1f, inset -4px -4px 0 #c43a52, inset 4px 4px 0 #ffa0ae;
  cursor: pointer;
}
.bt-pixel__btn--alt {
  background: #4fd1c5;
  box-shadow: 0 -4px 0 #1a0b1f, 0 4px 0 #1a0b1f, -4px 0 0 #1a0b1f, 4px 0 0 #1a0b1f, inset -4px -4px 0 #2a9d92, inset 4px 4px 0 #9ff0e8;
}
.bt-pixel__btn:hover {
  background: #ff7486;
}
.bt-pixel__btn--alt:hover {
  background: #6fe0d5;
}
.bt-pixel__btn:focus-visible {
  outline: 4px dashed #ffe14d;
  outline-offset: 8px;
}
.bt-pixel__btn:active {
  box-shadow: 0 -4px 0 #1a0b1f, 0 4px 0 #1a0b1f, -4px 0 0 #1a0b1f, 4px 0 0 #1a0b1f, inset 4px 4px 0 #c43a52;
}
.bt-pixel__btn--alt:active {
  box-shadow: 0 -4px 0 #1a0b1f, 0 4px 0 #1a0b1f, -4px 0 0 #1a0b1f, 4px 0 0 #1a0b1f, inset 4px 4px 0 #2a9d92;
}
.bt-pixel__btn:disabled {
  background: #9a90a5;
  color: #241a2b;
  box-shadow: 0 -4px 0 #1a0b1f, 0 4px 0 #1a0b1f, -4px 0 0 #1a0b1f, 4px 0 0 #1a0b1f, inset -4px -4px 0 #7a7086;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-pixel__btn:not(:disabled):active {
    transform: translateY(2px);
  }
}

Mechanical keycap

  • face + wall layers
  • translateY press
  • kbd legend

The button background is the side wall of the key and an inner span is the face, set 8px short of the bottom. Pressing slides the face down over the wall, so the travel is real geometry rather than a faked shadow. A small legend shows the keyboard shortcut the button mirrors.

View code
.bt-key {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-end;
  gap: 14px;
  padding: 24px min(24px, 5vw);
  border-radius: 18px;
  background: #c9cdd4;
}
.bt-key__btn {
  display: block;
  min-width: 76px;
  height: 66px;
  padding: 0 0 8px;
  border: 0;
  border-radius: 12px;
  background: #1f2228;
  color: #f4f1e8;
  font: inherit;
  font-weight: 700;
  box-shadow: 0 6px 12px rgba(20, 22, 28, .25);
  cursor: pointer;
}
.bt-key__face {
  display: grid;
  place-content: center;
  gap: 2px;
  height: 100%;
  padding: 0 18px;
  border-radius: 11px 11px 9px 9px;
  background: #3a3e48;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, .12), inset 0 -3px 0 rgba(0, 0, 0, .18);
}
.bt-key__btn--accent {
  min-width: 132px;
  background: #8f3216;
  color: #1c0d06;
}
.bt-key__btn--accent .bt-key__face {
  background: #f07a4f;
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, .3), inset 0 -3px 0 rgba(0, 0, 0, .12);
}
.bt-key__kbd {
  font: 600 .7rem/1 ui-monospace, Consolas, monospace;
  letter-spacing: .04em;
}
.bt-key__btn:hover .bt-key__face {
  background: #444955;
}
.bt-key__btn--accent:hover .bt-key__face {
  background: #f48a63;
}
.bt-key__btn:focus-visible {
  outline: 3px solid #1f3fd1;
  outline-offset: 4px;
}
.bt-key__btn:active .bt-key__face {
  transform: translateY(6px);
}
.bt-key__btn:disabled {
  background: #8c929c;
  color: #3b4049;
  box-shadow: none;
  cursor: not-allowed;
}
.bt-key__btn:disabled .bt-key__face {
  background: #b9bec6;
  transform: none;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-key__face {
    transition: transform .07s ease-out, background-color .15s ease;
  }
}

Button groups and composite buttons

Buttons rarely live alone. These are the shapes that combine several actions: a split button with a menu, a segmented control, share and download rows, and a floating action button that opens a speed dial.

Split button with an options menu

  • aria-expanded
  • hidden + @starting-style
  • aria-pressed options

Open the arrow, pick another merge method, then press Escape or tab away.

The main half runs the current action and the arrow half opens a list of alternatives. Picking one changes what the main half does, which is the whole point of a split button. The menu is a disclosure, not an ARIA menu, so Tab still works inside it; Escape and focus leaving the group both close it.

View code
.bt-split {
  position: relative;
  width: min(320px, 100%);
  min-height: 300px;
  padding-top: 8px;
}
.bt-split__group {
  display: flex;
  width: max-content;
  max-width: 100%;
}
.bt-split__main,
.bt-split__toggle {
  min-height: 48px;
  border: 0;
  background: #3b2fc9;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.bt-split__main {
  min-width: 0;
  padding: 0 20px;
  border-radius: 12px 0 0 12px;
}
.bt-split__toggle {
  display: grid;
  place-items: center;
  width: 48px;
  border-left: 1px solid rgba(255, 255, 255, .45);
  border-radius: 0 12px 12px 0;
}
.bt-split__toggle svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-split__main:hover,
.bt-split__toggle:hover,
.bt-split__toggle[aria-expanded="true"] {
  background: #2e23a8;
}
.bt-split__main:active,
.bt-split__toggle:active {
  background: #231a85;
}
.bt-split__main:focus-visible,
.bt-split__toggle:focus-visible {
  position: relative;
  outline: 3px solid #3b2fc9;
  outline-offset: 3px;
}
.bt-split__main:disabled,
.bt-split__toggle:disabled {
  background: #c9c7e0;
  color: #44415e;
  cursor: not-allowed;
}
.bt-split__menu {
  position: absolute;
  top: 66px;
  left: 0;
  width: 100%;
  margin: 0;
  padding: 6px;
  list-style: none;
  border: 1px solid #d8dbe6;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(20, 22, 40, .16);
}
.bt-split__opt {
  display: grid;
  gap: 2px;
  width: 100%;
  min-height: 52px;
  padding: 8px 12px 8px 36px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #171a2b;
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  position: relative;
}
.bt-split__opt small {
  color: #555b70;
  font-size: .82rem;
  font-weight: 400;
}
.bt-split__opt[aria-pressed="true"]::before {
  content: "";
  position: absolute;
  left: 13px;
  top: 16px;
  width: 6px;
  height: 11px;
  border: solid #3b2fc9;
  border-width: 0 2.5px 2.5px 0;
  transform: rotate(45deg);
}
.bt-split__opt:hover {
  background: #f0f0fb;
}
.bt-split__opt:active {
  background: #e2e1f7;
}
.bt-split__opt:focus-visible {
  outline: 3px solid #3b2fc9;
  outline-offset: -3px;
}
.bt-split__opt:disabled {
  color: #6b7084;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-split__menu {
    transition: opacity .18s ease, transform .18s ease;
  }
  @starting-style {
    .bt-split__menu {
      opacity: 0;
      transform: translateY(-6px);
    }
  }
}

Segmented control from radio buttons

  • fieldset + legend
  • input:checked + span
  • arrow-key native
Billing period

A segmented control is a single choice from a few options, which is exactly what radio buttons are. The real inputs stay in place under each segment, so arrow keys, form submission and screen reader announcements all come free. CSS only restyles the span after each input.

View code
.bt-seg {
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.bt-seg__legend {
  margin-bottom: 10px;
  padding: 0;
  color: #2a2f3d;
  font-weight: 700;
}
.bt-seg__track {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  padding: 4px;
  border: 1px solid #8b93a6;
  border-radius: 14px;
  background: #fff;
}
.bt-seg__opt {
  position: relative;
  flex: 1 1 auto;
}
.bt-seg__opt input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.bt-seg__opt span {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 44px;
  padding: 0 18px;
  border-radius: 10px;
  color: #2a2f3d;
  font-weight: 600;
  white-space: nowrap;
  transition: background-color .15s ease, color .15s ease;
}
.bt-seg__opt em {
  padding: 2px 6px;
  border-radius: 6px;
  background: #dcf5e6;
  color: #0d5c33;
  font-size: .75rem;
  font-style: normal;
}
.bt-seg__opt input:hover + span {
  background: #eef0f6;
}
.bt-seg__opt input:active + span {
  background: #e0e3ed;
}
.bt-seg__opt input:checked + span {
  background: #14182b;
  color: #fff;
}
.bt-seg__opt input:checked + span em {
  background: #2ea866;
  color: #04170c;
}
.bt-seg__opt input:focus-visible + span {
  outline: 3px solid #2346ff;
  outline-offset: 2px;
}
.bt-seg__opt input:disabled {
  cursor: not-allowed;
}
.bt-seg__opt input:disabled + span {
  color: #737a8c;
  background: transparent;
  text-decoration: line-through;
}

Share buttons with words, not logos

  • share URLs
  • visually hidden prefix
  • new tab notice

Plain words are easier to scan than a row of tiny logos, and they carry no trademark baggage. Each link has a hidden prefix, so a screen reader hears Share on LinkedIn while the eye only needs LinkedIn. These are links, not buttons, because they go somewhere, and the ones that open a new tab say so.

View code
.bt-share {
  width: min(460px, 100%);
  padding: 22px;
  border-radius: 18px;
  background: #fffaf2;
  border: 1px solid #eadfcd;
}
.bt-share__title {
  margin: 0 0 14px;
  color: #3a2f22;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.2rem;
  font-weight: 600;
}
.bt-share__list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.bt-share__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 18px;
  border: 1.5px solid #3a2f22;
  border-radius: 999px;
  color: #3a2f22;
  font-weight: 600;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
.bt-share__link--mail {
  background: #3a2f22;
  color: #fffaf2;
}
.bt-share__link:hover {
  background: #f1e4cf;
}
.bt-share__link--mail:hover {
  background: #54442f;
}
.bt-share__link:active {
  background: #e6d3b5;
}
.bt-share__link--mail:active {
  background: #241c13;
}
.bt-share__link:focus-visible {
  outline: 3px solid #b4400e;
  outline-offset: 3px;
}
.bt-share__link[aria-disabled="true"] {
  border-color: #a99d8d;
  color: #6d6457;
  pointer-events: none;
}
.bt-share__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

App download buttons

  • two-line label
  • generic device icons
  • link styled as button

Tidewell, the tide table for surfers

The familiar store-badge shape without anyone else's badge: a dark tile, a small line above and the platform in bold below. The icons are generic device outlines drawn inline. The accessible name reads as one phrase, Download for iOS, because both lines live in the same link.

View code
.bt-store {
  display: grid;
  justify-items: center;
  gap: 16px;
}
.bt-store__app {
  margin: 0;
  color: #2b3240;
  font-weight: 600;
  text-align: center;
}
.bt-store__row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}
.bt-store__btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-width: 168px;
  min-height: 58px;
  padding: 8px 18px 8px 14px;
  border: 1px solid #5d5d66;
  border-radius: 12px;
  background: #0d0d10;
  color: #fff;
  text-decoration: none;
  transition: background-color .15s ease;
}
.bt-store__btn svg {
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-store__text {
  display: grid;
  font-size: 1.15rem;
  font-weight: 700;
  line-height: 1.15;
}
.bt-store__text small {
  color: #d3d3da;
  font-size: .72rem;
  font-weight: 500;
  letter-spacing: .02em;
}
.bt-store__btn:hover {
  background: #26262c;
}
.bt-store__btn:active {
  background: #000;
}
.bt-store__btn:focus-visible {
  outline: 3px solid #2346ff;
  outline-offset: 3px;
}
.bt-store__btn[aria-disabled="true"] {
  background: #9a9aa3;
  color: #1d1d22;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-store__btn {
    transition: background-color .15s ease, transform .12s ease;
  }
  .bt-store__btn:active {
    transform: scale(.97);
  }
}

Floating action button with a speed dial

  • position: absolute
  • aria-expanded
  • labeled mini actions

Notes

  • Grocery runOat milk, limes, rye bread
  • Trip ideasLofoten in March, check ferries
  • StandupShip the pricing copy today
  • ReadingFinish chapter 7 of the type book

Tap the round button in the corner.

The floating button sits in the thumb zone and opens two labeled actions above it. Every mini action has visible text, since a stack of bare icons is guesswork. The plus turns into a close mark, but the accessible name stays Create note and aria-expanded carries the state; Escape closes the dial.

View code
.bt-fab {
  position: relative;
  height: 100%;
  overflow: hidden;
  padding: 18px 18px 0;
  background: #f6f3ec;
  color: #23201a;
}
.bt-fab__head {
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.6rem;
  font-weight: 600;
}
.bt-fab__list {
  display: grid;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.bt-fab__list li {
  display: grid;
  gap: 2px;
  padding: 14px 16px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(35, 32, 26, .08);
}
.bt-fab__list span {
  color: #5c564a;
  font-size: .9rem;
}
.bt-fab__btn {
  position: absolute;
  right: 20px;
  bottom: 22px;
  display: grid;
  place-items: center;
  width: 60px;
  height: 60px;
  border: 0;
  border-radius: 18px;
  background: #1f5c4a;
  color: #fff;
  box-shadow: 0 8px 20px rgba(31, 92, 74, .35);
  cursor: pointer;
  transition: background-color .15s ease;
}
.bt-fab__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
}
.bt-fab__btn:hover {
  background: #184a3c;
}
.bt-fab__btn:active {
  background: #10362b;
}
.bt-fab__btn:focus-visible {
  outline: 3px solid #1f5c4a;
  outline-offset: 4px;
}
.bt-fab__btn:disabled {
  background: #a9b8b2;
  box-shadow: none;
  cursor: not-allowed;
}
.bt-fab__btn[aria-expanded="true"] svg {
  transform: rotate(45deg);
}
.bt-fab__dial {
  position: absolute;
  right: 20px;
  bottom: 96px;
  display: grid;
  justify-items: end;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.bt-fab__mini {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid #c9c1b0;
  border-radius: 12px;
  background: #fff;
  color: #1f2a26;
  font: inherit;
  font-weight: 600;
  box-shadow: 0 6px 16px rgba(35, 32, 26, .14);
  cursor: pointer;
}
.bt-fab__mini:hover {
  background: #eef5f2;
}
.bt-fab__mini:active {
  background: #dcebe5;
}
.bt-fab__mini:focus-visible {
  outline: 3px solid #1f5c4a;
  outline-offset: 3px;
}
.bt-fab__mini:disabled {
  color: #6f6a60;
  cursor: not-allowed;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-fab__btn svg {
    transition: transform .2s ease;
  }
  .bt-fab__dial li {
    transition: opacity .2s ease, transform .2s ease;
  }
  .bt-fab__dial li:nth-child(2) {
    transition-delay: .04s;
  }
  @starting-style {
    .bt-fab__dial li {
      opacity: 0;
      transform: translateY(10px);
    }
  }
}

Buttons with real states

Where a button has to tell people what just happened. Each demo keeps the state in an attribute (aria-pressed, aria-expanded, data-state) and lets the CSS read it, so what you see and what a screen reader hears stay in step.

Icon-only buttons with tooltips

  • name from content
  • :hover / :focus-visible
  • Escape to dismiss

Hover or tab through the toolbar; Escape hides the tooltip.

Each tooltip is a span inside its button, so its text is the accessible name and there is nothing to keep in sync. It shows on hover and on keyboard focus, stays while the pointer moves onto it, and Escape hides it without moving focus, which covers WCAG 1.4.13. The buttons are 44px even though the icons are 20px.

View code
.bt-tools {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin-top: 58px;
  padding: 6px;
  border: 1px solid #c9cedb;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(20, 26, 40, .08);
}
.bt-tools__btn {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #1d2433;
  cursor: pointer;
  transition: background-color .15s ease;
}
.bt-tools__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-tools__btn:hover {
  background: #eef1f7;
}
.bt-tools__btn:active {
  background: #dde2ee;
}
.bt-tools__btn:focus-visible {
  outline: 3px solid #2346ff;
  outline-offset: 1px;
}
.bt-tools__btn:disabled {
  color: #8a91a1;
  cursor: not-allowed;
}
.bt-tools__btn:disabled:hover {
  background: transparent;
}
.bt-tools__tip {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  z-index: 2;
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 7px 10px;
  border-radius: 8px;
  background: #151a26;
  color: #fff;
  font-size: .82rem;
  font-weight: 600;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  translate: -50% 0;
  transition: opacity .15s ease;
}
.bt-tools__tip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  translate: -50% 0;
  border: 6px solid transparent;
  border-top-color: #151a26;
}
.bt-tools__tip::before {
  content: "";
  position: absolute;
  inset: 100% 0 -12px;
}
.bt-tools__tip kbd {
  padding: 1px 5px;
  border-radius: 4px;
  background: #343b4d;
  color: #e5e8f0;
  font: 600 .72rem/1.4 ui-monospace, Consolas, monospace;
}
.bt-tools__btn:hover .bt-tools__tip,
.bt-tools__btn:focus-visible .bt-tools__tip {
  opacity: 1;
  pointer-events: auto;
}
.bt-tools--quiet .bt-tools__btn .bt-tools__tip {
  opacity: 0;
  pointer-events: none;
}
.bt-tools__btn:disabled .bt-tools__tip {
  color: #c8ccd6;
}

Follow toggle with aria-pressed

  • aria-pressed
  • stable accessible name
  • CSS reads the state

Maya Rios

2,481 followers

A toggle button keeps one name and flips aria-pressed, so a screen reader says Follow, toggle button, pressed. The visible Following comes from an aria-hidden suffix, which shows the new state on screen without changing the name. All of the styling hangs off the attribute, so the script only flips one value and the count.

View code
.bt-follow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px 16px;
  width: min(420px, 100%);
  padding: 18px 20px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(20, 26, 40, .08);
}
.bt-follow__avatar {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: #ffd9c7;
  color: #7a2e0e;
  font-weight: 700;
}
.bt-follow__who {
  flex: 1 1 120px;
}
.bt-follow__name {
  margin: 0;
  color: #161a24;
  font-weight: 700;
}
.bt-follow__meta {
  margin: 0;
  color: #596173;
  font-size: .9rem;
}
.bt-follow__btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 128px;
  min-height: 44px;
  justify-content: center;
  padding: 0 18px;
  border: 2px solid #c2410c;
  border-radius: 999px;
  background: #c2410c;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease, color .15s ease, border-color .15s ease;
}
.bt-follow__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-follow__check,
.bt-follow__ing {
  display: none;
}
.bt-follow__btn:hover {
  background: #9a3412;
  border-color: #9a3412;
}
.bt-follow__btn:active {
  background: #7c2d12;
}
.bt-follow__btn:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
.bt-follow__btn[aria-pressed="true"] {
  background: #fff;
  border-color: #bfc5d2;
  color: #161a24;
}
.bt-follow__btn[aria-pressed="true"]:hover {
  background: #f3f4f7;
}
.bt-follow__btn[aria-pressed="true"]:active {
  background: #e6e8ee;
}
.bt-follow__btn[aria-pressed="true"] .bt-follow__plus {
  display: none;
}
.bt-follow__btn[aria-pressed="true"] .bt-follow__check {
  display: block;
  color: #157347;
}
.bt-follow__btn[aria-pressed="true"] .bt-follow__ing {
  display: inline;
}
.bt-follow__btn:disabled {
  border-color: #d3d6de;
  background: #e8eaef;
  color: #5d6475;
  cursor: not-allowed;
}

Save button with loading and done states

  • data-state
  • aria-disabled
  • role="status"

While saving, the button switches to aria-disabled instead of disabled, so it keeps keyboard focus and extra clicks are ignored in the script. The three labels share one grid cell, so the width never jumps between Save changes, Saving and Saved. A separate status region announces each step, and the spinner only spins when motion is allowed.

View code
.bt-load {
  display: grid;
  gap: 8px;
  width: min(380px, 100%);
  padding: 22px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(20, 26, 40, .08);
}
.bt-load__label {
  color: #1b2030;
  font-weight: 600;
}
.bt-load__input {
  min-height: 44px;
  padding: 0 12px;
  border: 1px solid #8a92a4;
  border-radius: 10px;
  color: #1b2030;
  font: inherit;
}
.bt-load__input:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 1px;
}
.bt-load__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  margin-top: 8px;
}
.bt-load__btn {
  display: grid;
  min-height: 48px;
  padding: 0 22px;
  border: 0;
  border-radius: 12px;
  background: #1d4ed8;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bt-load__lbl {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  visibility: hidden;
}
.bt-load__btn[data-state="idle"] .bt-load__lbl--idle,
.bt-load__btn[data-state="busy"] .bt-load__lbl--busy,
.bt-load__btn[data-state="done"] .bt-load__lbl--done {
  visibility: visible;
}
.bt-load__lbl svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-load__spin {
  width: 16px;
  height: 16px;
  border: 2.5px solid rgba(255, 255, 255, .4);
  border-top-color: #fff;
  border-radius: 50%;
}
.bt-load__btn:hover {
  background: #1e40af;
}
.bt-load__btn:active {
  background: #1e3a8a;
}
.bt-load__btn:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
.bt-load__btn[aria-disabled="true"] {
  cursor: progress;
}
.bt-load__btn[data-state="busy"] {
  background: #3a5fcf;
}
.bt-load__btn[data-state="done"] {
  background: #15703f;
}
.bt-load__btn:disabled {
  background: #c5cbd8;
  color: #3f4656;
  cursor: not-allowed;
}
.bt-load__status {
  margin: 0;
  color: #4b5366;
  font-size: .9rem;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-load__spin {
    animation: bt-spin .8s linear infinite;
  }
}
@keyframes bt-spin {
  to {
    transform: rotate(1turn);
  }
}

Copy to clipboard with a success state

  • navigator.clipboard
  • data-state
  • select fallback

Install the CLI

npm install -g lanternkit-cli

The clipboard is touched only inside the click handler and only ever receives the fixed command stored on the button. Success swaps the icon and label for two seconds and the status region says Copied to clipboard. If the browser refuses, the command text gets selected and the status asks for Ctrl+C instead.

View code
.bt-copy {
  width: min(460px, 100%);
}
.bt-copy__label {
  margin: 0 0 8px;
  color: #c3cad9;
  font-size: .9rem;
  font-weight: 600;
}
.bt-copy__box {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 8px 8px 16px;
  border: 1px solid #2f3a52;
  border-radius: 14px;
  background: #141b2b;
}
.bt-copy__code {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
  color: #e8ecf5;
  font: .9rem/1.4 ui-monospace, Consolas, monospace;
}
.bt-copy__btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 104px;
  min-height: 44px;
  justify-content: center;
  padding: 0 14px;
  border: 1px solid #45526e;
  border-radius: 10px;
  background: #222c42;
  color: #f1f4fa;
  font: inherit;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.bt-copy__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bt-copy__ok {
  display: none;
}
.bt-copy__btn:hover {
  background: #2c3853;
}
.bt-copy__btn:active {
  background: #19212f;
}
.bt-copy__btn:focus-visible {
  outline: 3px solid #7dd3fc;
  outline-offset: 2px;
}
.bt-copy__btn[data-state="copied"] {
  border-color: #4ade80;
  background: #12301f;
  color: #bbf7d0;
}
.bt-copy__btn[data-state="copied"] .bt-copy__icon {
  display: none;
}
.bt-copy__btn[data-state="copied"] .bt-copy__ok {
  display: block;
}
.bt-copy__btn[data-state="failed"] {
  border-color: #fbbf24;
}
.bt-copy__btn:disabled {
  border-color: #333c4f;
  background: #1a2130;
  color: #8a93a6;
  cursor: not-allowed;
}
.bt-copy__status {
  min-height: 1.4em;
  margin: 8px 0 0;
  color: #b5bdcc;
  font-size: .88rem;
}

Hold to confirm a destructive action

  • pointer events
  • progress bar transition
  • two-step keyboard path

Delete workspace

Removes 14 projects and 230 files for everyone on the team. This cannot be undone.

Press and hold the button for about a second, or press Enter twice.

Pointer users hold the button for 1.2 seconds while a bar fills along its bottom edge; letting go early cancels. Holding a key is awkward, so keyboard activation takes a different path: the first press arms the button and changes its label, the second press within four seconds confirms. The timer lives in the script, so the result is the same with or without motion.

View code
.bt-hold {
  width: min(420px, 100%);
  padding: 22px;
  border: 1px solid #f1c4c4;
  border-radius: 18px;
  background: #fff;
}
.bt-hold__title {
  margin: 0 0 4px;
  color: #7f1d1d;
  font-weight: 700;
}
.bt-hold__text {
  margin: 0 0 16px;
  color: #4b4f5c;
  font-size: .92rem;
}
.bt-hold__row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.bt-hold__btn {
  position: relative;
  min-width: 210px;
  min-height: 52px;
  padding: 0 22px 4px;
  overflow: hidden;
  border: 2px solid #b91c1c;
  border-radius: 12px;
  background: #fef2f2;
  color: #991b1b;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  transition: background-color .15s ease;
}
.bt-hold__bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 6px;
  background: #b91c1c;
  transform: scaleX(0);
  transform-origin: left;
}
.bt-hold__btn:hover {
  background: #fee2e2;
}
.bt-hold__btn:active,
.bt-hold__btn[data-state="holding"] {
  background: #fecaca;
}
.bt-hold__btn[data-state="armed"] {
  background: #b91c1c;
  color: #fff;
}
.bt-hold__btn:focus-visible {
  outline: 3px solid #b91c1c;
  outline-offset: 3px;
}
.bt-hold__btn:disabled {
  border-color: #c9ccd4;
  background: #f1f2f5;
  color: #4b4f5c;
  cursor: not-allowed;
}
.bt-hold__undo {
  min-height: 52px;
  padding: 0 18px;
  border: 1px solid #8a92a4;
  border-radius: 12px;
  background: #fff;
  color: #1b2030;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.bt-hold__undo:hover {
  background: #f3f4f7;
}
.bt-hold__undo:active {
  background: #e6e8ee;
}
.bt-hold__undo:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
.bt-hold__undo:disabled {
  color: #6b7080;
  cursor: not-allowed;
}
.bt-hold__status {
  min-height: 1.4em;
  margin: 10px 0 0;
  color: #4b4f5c;
  font-size: .88rem;
}
@media (prefers-reduced-motion: no-preference) {
  .bt-hold__btn[data-state="holding"] .bt-hold__bar {
    transform: scaleX(1);
    transition: transform 1.2s linear;
  }
}
@media (prefers-reduced-motion: reduce) {
  .bt-hold__bar {
    transform: none;
    opacity: 0;
  }
  .bt-hold__btn[data-state="holding"] .bt-hold__bar {
    opacity: 1;
    transition: opacity 1.2s linear;
  }
}

Which cool CSS button effects are worth building?

Hover fill, gradient and 3D press buttons are the safest to build, and neumorphism is the riskiest. The table ranks them from lowest risk to highest.

EffectCore propertiesMain riskVerdict
Hover fill and slide::before, transformLabel contrast mid-fillBuild first
Gradientlinear-gradient, background-positionSnaps on transitionBuild
3D pressbox-shadow, transformShadow repaintBest for main buttons
Glowbox-shadow, text-shadowVanishes on light surfacesUse sparingly
Animated gradient border@property, conic-gradientNeeds @propertyAdd a static fallback
Glassmorphismbackdrop-filter, rgba()Contrast shifts with the backdropControlled backgrounds only
NeumorphismTwo box-shadow layersShadow-only boundarySkip for icon-only buttons

For a main button, a gradient or a 3D press does the job. A secondary button is fine with a hover fill. Icon buttons can take a hover fill too, or a color-matched glow.

Hover fill and slide buttons

A pseudo-element sits behind the label, starts outside the button’s edge, and slides in on hover. It takes the least code of any effect here and forgives the most mistakes.

The core set is position: relative and overflow: hidden on the button, transform: translateX(-100%) on the ::before layer, and translateX(0) on :hover.

Only transform moves, so the animation stays cheap. The catch is the label. It has to meet WCAG 1.4.3 text contrast against both the resting color and the fill color, and keyboard focus needs to trigger the same fill.

Gradient buttons

The base is background: linear-gradient(135deg, #3b82f6, #8b5cf6), with two or three stops.

Where it goes wrong is the transition between two gradients. Browsers do not interpolate gradient images, so the color change snaps instead of fading. Enlarge the fill with background-size: 200% 100% and move background-position on hover, or fade a gradient layer in with opacity. Check the label contrast at the lightest stop too, because the text crosses the whole range.

One gradient button per view is plenty. Put three on the screen and they fight over the same click.

3D press buttons

The depth comes from a hard shadow and the press from a transform.

A solid shadow such as box-shadow: 0 6px 0 #1e3a8a draws the edge. On :active, transform: translateY(4px) with the shadow cut to 0 2px 0 makes the button sink into its base.

Variations that add perspective and layered edges build on the same base, as shown in these CSS 3D button examples.

It works well on main call-to-action buttons and on touch screens, where the press is easy to see. It falls short in flat or minimalist layouts, and on pages with lots of buttons, since the shadow repaints on each state change.

Glowing buttons

A glow is a blurred, color-matched box-shadow with no offset, such as 0 0 16px rgba(80, 140, 255, 0.6). A matching text-shadow carries it onto the label.

On dark surfaces it reads well. On white ones it disappears.

It’s one declaration with no extra markup, which is the nice part. Animating the blur radius repaints the button, though, so fade a separate glow layer with opacity instead.

Animated gradient border buttons

MDN lists @property as Baseline 2024, newly available since July 2024. That date is the support floor for this effect.

The registration needs a syntax of "<angle>", an inherits value (false here) and an initial-value, or the whole rule is ignored. A conic-gradient border reads the registered --angle, and @keyframes turns it from 0deg to 360deg.

Without registration the browser treats --angle as plain text, so the angle jumps instead of turning.

Where @property is missing, fall back to a static gradient border. And stop the loop under prefers-reduced-motion.

Glassmorphism buttons

The recipe pairs backdrop-filter: blur(10px) with a translucent fill such as rgba(255, 255, 255, 0.15). The blur only shows when the element or its background is partly transparent.

Keep a -webkit-backdrop-filter line for older Safari builds. The wider technique is covered in this glassmorphism CSS guide.

Text contrast depends on whatever sits behind the button, so test the label against the lightest and darkest background it can meet.

Web Platform DX data lists backdrop-filter as Baseline newly available since 16 September 2024, the day Safari 18 shipped it.

Neumorphism buttons

Neumorphism ranks last on purpose.

The button matches its background and takes its shape from two shadows, one light and one dark, as in box-shadow: 6px 6px 12px #bebebe, -6px -6px 12px #ffffff.

W3C’s Understanding document for WCAG 1.4.11 asks for a 3:1 contrast ratio on visual information that identifies controls and their states. A button with a visible text label rests on its text contrast, but an icon-only neumorphic button depends on the shadow alone, so the shadow has to clear 3:1 against the adjacent color.

I’d skip it for icon-only buttons and for the main action. On labeled secondary controls it’s tolerable, since the text carries the contrast.

Which CSS properties do most button effects rely on?

Most button effects come down to background, border-radius, box-shadow, transform and transition. Text-shadow, pseudo-elements and custom properties cover the rest.

  • background sets a solid color or a linear-gradient() fill
  • border-radius runs from 8px for a soft rectangle to 999px for a pill, or 50% for a round icon button
  • box-shadow handles elevation, glow and 3D edges, all built from comma-separated layers
  • transform gives you scale(1.05) for a grow and translateY(-2px) for a lift
  • transition decides which properties change, how long it takes, and what easing applies

padding and min-height sit alongside these and set the hit area, so a short label never collapses the button.

Layered shadows are easier to tune by eye, and a visual box shadow generator shows each layer as it changes.

Gradient stops behave the same way. A CSS gradient generator saves trial and error before the values reach the stylesheet, and a CSS button generator sets the background, radius, border and shadow of a whole button at once.

The ::before and ::after pseudo-elements add overlay layers for slide fills, shine sweeps and glow without extra HTML. Each needs content: "" and position: absolute inside a button set to position: relative.

web.dev’s compositing guidance says to stick to transform and opacity for animation, because changes to them avoid layout and paint.

Syntax and accepted values for every property above are documented on MDN Web Docs.

Color variants with CSS variables

Custom properties let one rule serve every color variant. Define the colors on the base class, then override them per modifier.

.btn { background: var(--btn-bg); color: var(--btn-fg); } plus .btn--danger { --btn-bg: #c62828; --btn-fg: #ffffff; } produces a new variant with no duplicated declarations.

CSS variables resolve in the browser, so a theme switch or a media query can change them at runtime. Sass variables are resolved at build time instead, which suits loops that generate variant classes from a color map.

Projects already on Sass can use both. Maps generate the classes, and custom properties hold the values that change in the browser.

How do you style hover, focus, active and disabled states?

Write the states in cascade order: default, :hover, :focus-visible, :active, then :disabled. Rules with equal specificity are resolved by order, so the later state wins when two apply at once.

  1. Write the default state with a min-height, a border, and transition on named properties only.
  2. Add :hover for a color or elevation change.
  3. Add :focus-visible with an outline, for example outline: 3px solid #1d4ed8; outline-offset: 3px.
  4. Add :active after :hover, so the press look wins while the pointer is down.
  5. Add :disabled last, with reduced emphasis and cursor: not-allowed.

More hover treatments, from fills to underline wipes, are collected in these CSS button hover effects.

Hover and active states

Hover tells people the button is clickable, and active confirms the press. Both should change something visible without moving the surrounding layout.

  • :hover shifts the color, lifts with translateY(-2px), or raises the shadow
  • :active sinks with translateY(1px) or drops the shadow

Hover is an enhancement. Touch screens have no hover, so no state may carry meaning that only hover reveals.

Why :focus-visible replaces :focus

MDN classes :focus-visible as Baseline widely available. It styles the focused element only when the browser decides the user needs to see where focus is, so mouse clicks skip the ring while keyboard tabbing shows it.

Never ship outline: none without a replacement. MDN warns that a removed outline has to be replaced with one that passes WCAG 1.4.11 non-text contrast.

Disabled state

The disabled attribute blocks clicks and removes the button from the tab order. Styling alone with opacity or pointer-events: none leaves keyboard activation intact, which is the usual mistake.

For the look, lower the contrast and use a not-allowed cursor. WCAG 1.4.11 exempts inactive components from its contrast requirement, according to W3C’s Understanding document.

One cascade trap to know about: :hover still matches a disabled button. Put the :disabled rule last, or add :not(:disabled) to the hover rule.

Should a button animation use a transition or keyframes?

Use a transition for state changes and @keyframes for loops or multi-step effects. A transition needs a trigger and two states, while keyframes can run without one.

AspectTransitionKeyframes
TriggerState change such as :hoverPage load or a class toggle
StopsStart and endAny number
Typical button effectHover fill, pressPulse, shine sweep, rotating border

Transitions for state changes

Name the properties instead of writing transition: all. The all keyword animates every property that changes, including ones that trigger layout or paint.

A typical declaration reads transition: transform 100ms ease-out, background-color 100ms ease-out.

Keyframes for loops and multi-step effects

A pulse loops transform: scale() between two values, and a shine sweep translates a pseudo-element across the button. The rotating border from earlier turns the registered --angle property from 0deg to 360deg.

A fuller look at CSS keyframes covers percentages, direction and fill modes.

Infinite loops hold attention whether the user wants it held or not, so pair them with prefers-reduced-motion.

Duration and easing values

NN/g puts most UI animations between 100 and 500 ms, scaled to complexity and travel distance. Simple feedback sits near 100 ms, and at 500 ms animations start to feel like a drag.

For buttons, that puts a press at the low end and a slide fill somewhat higher, still well short of 500 ms. NN/g also flags fully linear motion as unnatural.

ease-out starts fast and lands softly, which suits hover-in. ease-in-out works better for loops that return to their starting point.

The Animations panel in Chrome DevTools replays a transition at reduced speed, which makes easing curves easier to judge.

How do you keep cool CSS buttons accessible?

Every state of the button needs its text contrast, hit area, focus indicator and motion checked. An effect that fails any of them locks some users out.

The numbers below come from the W3C’s WCAG 2.2 documents.

  • The minimum target size (SC 2.5.8, Level AA) is 24 by 24 CSS pixels
  • The enhanced target size (SC 2.5.5, Level AAA) is 44 by 44 CSS pixels
  • Text contrast (SC 1.4.3, Level AA) needs 4.5:1 for regular text and 3:1 for large text
  • Under SC 2.2.2 (Level A), anything moving for more than five seconds beside other content needs a pause, stop or hide mechanism

Contrast in every state

According to W3C’s Understanding document, SC 1.4.3 covers text shown on hover and on keyboard focus. A label that passes at rest can still fail on the hover fill.

Test the label in default, hover, focus and active states. A disabled button is exempt as an inactive component.

W3C defines large text as 18 point, or 14 point bold. That works out to about 24 CSS pixels at regular weight, or about 18.7 CSS pixels in bold, so a bold 16px button label still needs the full 4.5:1.

Gradient and glass buttons multiply the work, since a color contrast checker compares two colors at a time and each gradient stop is a new pair.

Hit area and target size

SC 2.5.8 measures the clickable area, not the visible glyph. Padding and min-height grow the hit area without touching the icon.

A 16px icon button with 4px of padding on each side reaches 24 by 24.

The spacing exception covers undersized buttons. According to the text of SC 2.5.8, a 24px circle centered on each undersized target must not overlap another target or another undersized target’s circle, so a toolbar of tiny buttons with no gap fails.

A focus indicator that survives the effect

Glow, gradient and glass backgrounds can swallow a thin focus ring.

Use outline with outline-offset, which sits outside the button and keeps its own contrast. A box-shadow ring is a bad bet on its own, because forced colors mode (Windows contrast themes) sets box-shadow to none while outlines stay. And pick a ring color that contrasts with the page behind the button, not only with the button itself.

Reduced motion and looping animation

MDN describes prefers-reduced-motion as a request to remove, reduce or replace motion-based animation. Replacing keeps the feedback: swap the scale for a color change instead of deleting the effect.

@media (prefers-reduced-motion: reduce) { .btn { transition: background-color 100ms ease-out; } .btn:hover { transform: none; } .btn--pulse { animation: none; } }

The media query only reaches users who changed an operating system setting, and SC 2.2.2 sets its own floor. A looping pulse needs a finite animation-iteration-count that ends inside five seconds, or a pause control.

Native semantics before ARIA

W3C’s Using ARIA note puts one rule first: use a native element when it already carries the semantics and behavior needed. A <button> brings keyboard focus and Enter and Space activation by default.

A styled div with role="button" has to rebuild all of that by hand. Keep ARIA attributes for the gaps, such as an aria-label on an icon-only button with no visible text.

When do cool CSS button effects break or backfire?

Effects break when an animated property forces repaints or when a browser lacks the feature. Input devices that can’t hover cause a third kind of trouble. All of them have a guard that costs a few lines of CSS.

Repaints and layer memory

Promoting every button to its own compositor layer trades smoothness for memory.

MDN warns that excessive will-change use means excessive memory use and more complex rendering. It also recommends setting the property from script around the interaction instead of leaving it in the stylesheet.

The Rendering panel in Chrome DevTools makes the cost visible. Paint flashing turns repainted areas green, and Layer borders outline the compositor layers.

A button animated with transform alone should not flash. If it does, a paint-heavy property such as a shadow blur is animating somewhere.

Support gaps and fallbacks

Write the baseline outside @supports and the enhancement inside, so an unsupported browser still gets a usable button. MDN’s feature query guide describes the same split.

.glass { background: rgba(20, 20, 30, 0.85); } @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { .glass { background: rgba(255, 255, 255, 0.15); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } }

The query lists both forms because Safari 17 and earlier only understand the prefixed property, and a test for the unprefixed one alone would send them to the dark fallback.

The fallback fill is darker on purpose. Without the blur, a 15 percent white fill leaves the label sitting directly on whatever is behind it.

A feature query tests whether the browser parses a property and value, not whether the effect renders well. The contrast check still runs on both paths.

Per-version tables live on Can I use, and the wider discipline is covered under cross-browser compatibility.

Hover on touch screens

Touch browsers emulate hover on tap, so a hover style can stay stuck after the finger lifts.

Wrap hover-only effects in @media (hover: hover). This media query tests the primary input device.

(hover: hover) follows the primary input, so a phone or a touch-first tablet keeps hover effects off.

Then there’s (any-hover: hover), which looks at every attached input. A tablet with a trackpad or mouse attached can pass this test, which brings hover effects back along with the stuck-hover risk on taps.

For buttons, default to (hover: hover). A missing hover effect on a hybrid device costs less than a stuck one on a phone.

Should you write cool CSS buttons yourself or copy them from a library?

Custom CSS suits buttons that are part of the brand, and a framework suits a project that already ships one. A copied snippet is fine for a one-off. What decides it is who maintains the buttons six months from now.

Custom CSS with CSS variables

You get full control over every state, no extra dependency to update, and effects that match the design exactly. The price is that every accessibility check is yours to run, and the first button takes more hours to ship.

Copy-paste libraries and demos

Uiverse publishes its elements under the MIT License, in CSS or Tailwind versions, and CodePen demos fill the same role. Licensing is rarely the problem. Code age is.

CSS Portal’s gradient button tutorial, for example, still lists -moz-linear-gradient and -o-linear-gradient lines that current browsers no longer need.

Audit any copied snippet before it reaches production:

  1. Confirm that :hover, :focus-visible, :active and :disabled all exist.
  2. Check each vendor-prefixed line against the browsers you target, and delete the ones that no longer apply.
  3. Test label contrast in each state.
  4. Replace transition: all with named properties.

Fast and free, but the states are often missing and the code is often old.

Tailwind CSS

Tailwind writes each state into the class list. The hover:, focus-visible:, disabled: and motion-reduce: variants cover the four states and reduced motion without a separate stylesheet, per Tailwind’s docs.

Ready-made Tailwind button patterns show how those variants stack on a single element.

Focus and reduced-motion handling sit one prefix away, which is the best thing about it. The downside is long class lists that repeat across buttons until they move into a component.

Bootstrap

Bootstrap is the safe baseline rather than the cool one.

Its docs describe a base .btn class, color variants, .btn-outline-* modifiers, and sizing that can be adjusted through CSS variables or Sass variables. The Bootstrap button classes work on a <button> element, and the docs allow them on anchors and inputs too.

States and sizes ship built in, and variants stay consistent across a team. Glow, glassmorphism and animated borders are another story, since they mean overriding defaults, and each override adds specificity to manage.

The two approaches combine well. Keep .btn for structure and add one modifier class that carries the effect.

Cool CSS Buttons FAQ

Can a ripple effect work without JavaScript?

Partly. CSS can fake a ripple from the button’s center using :active and a scaling pseudo-element, but it cannot read where the pointer landed. A ripple that starts at the exact click point needs a few lines of JavaScript to pass coordinates into CSS variables.

A fuller breakdown sits in this guide to the CSS ripple effect.

Should a styled button be a button element or an anchor element?

Use a button element for actions such as submitting a form or opening a dialog, and an anchor with href for navigation to another URL. Both take identical CSS, so the visual result does not decide the choice.

Anchors have no disabled attribute, which is one more reason to keep actions on buttons.

How do you add an icon to a CSS button and align it with the label?

Place an inline SVG next to the label and set the button to display: inline-flex with align-items: center and a gap. Size the icon in em so it scales with the text.

Mark a decorative icon with aria-hidden="true" when a visible label already names the action.

Does a button need cursor: pointer?

No. Browsers show the default arrow on a button element, and cursor: pointer is a style choice that signals clickability, borrowed from links.

If the project uses it, pair it with cursor: not-allowed on the :disabled state so the two states read differently.

How to Test a Button Before It Ships

Test cool CSS buttons keyboard first, then touch, then reduced motion. That order follows severity. A missing focus ring blocks keyboard users outright, while a stuck hover only degrades the page.

  • Tab to the button and confirm a visible focus ring
  • Tap it on a phone and watch for stuck hover
  • Enable reduce motion and check that feedback remains

The reduced-motion variant strips part of the effect, so the button looks plainer for the users who request it. That’s the cost, and it’s worth paying.

Web Platform DX projects backdrop-filter to reach Baseline widely available on 16 March 2027 (position verified October 2026). After that date, a glassmorphism button can drop its @supports fallback on projects that target widely available features.

A broader pass over the rest of the page belongs in a web accessibility checklist.