CSS Button Hover Effects: 16 Examples You Can Copy

CSS button hover effects change a button's color, shadow or position while the pointer sits on it. Mobile devices produced 59.06% of worldwide web traffic in September 2026, per StatCounter, so treat hover as confirmation, never as the only cue.

Below are 16 CSS button hover effects you can try and copy, followed by a guide to transitions, states, touch and accessibility.

By Bogdan Sandu ยท Updated October 27, 2026

Fills and sweeps

Color that moves into the button: from the side the pointer came in, from the exact spot under the cursor, as a skewed stripe, or as a ripple from the click point. Where the label changes color, a second copy of it rides inside the fill, so every letter keeps its contrast mid-animation.

Fill from the side you came in

  • pointerenter
  • clip-path: inset()
  • duplicate label

Hover a button, or press Tab to focus it.

A tiny script finds the edge nearest the pointer on enter and leave, writes it to data-from, and the fill wipes in from that side and out through the side you left by. The fill carries its own white copy of the label, so the text is always dark on white or white on navy, never half and half. Without the script, the button falls back to a plain :hover wipe from the bottom.

View code
.bh-dir-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 14px;
}
.bh-dir {
  position: relative;
  display: inline-grid;
  place-items: center;
  min-height: 52px;
  padding: 0 30px;
  border: 2px solid #14213d;
  border-radius: 999px;
  background: #fff;
  color: #14213d;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
}
.bh-dir__fill {
  position: absolute;
  inset: -2px;
  display: grid;
  place-items: center;
  border-radius: inherit;
  background: #14213d;
  color: #fff;
  pointer-events: none;
  clip-path: inset(100% 0 0 0);
}
.bh-dir[data-from="top"] .bh-dir__fill {
  clip-path: inset(0 0 100% 0);
}
.bh-dir[data-from="left"] .bh-dir__fill {
  clip-path: inset(0 100% 0 0);
}
.bh-dir[data-from="right"] .bh-dir__fill {
  clip-path: inset(0 0 0 100%);
}
.bh-dir:not([data-from]):hover .bh-dir__fill,
.bh-dir.is-in .bh-dir__fill,
.bh-dir:focus-visible .bh-dir__fill {
  clip-path: inset(0);
}
.bh-dir:focus-visible {
  outline: 3px solid #14213d;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-dir__fill {
    transition: clip-path .32s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-dir.is-snap .bh-dir__fill {
    transition: none;
  }
}

Circle reveal from the cursor

  • clip-path: circle()
  • custom properties
  • pointermove

Hover a button, or press Tab to focus it.

The pointer position is written to --bh-x and --bh-y, and the fill grows as a circle centered on that point. Because the center is part of the transitioned clip-path, the circle trails the cursor slightly as it moves, which reads as liquid rather than mechanical. Keyboard focus opens it from the middle.

View code
.bh-spot {
  position: relative;
  display: inline-grid;
  place-items: center;
  min-height: 56px;
  padding: 0 34px;
  border: 2px solid #f4d58d;
  border-radius: 14px;
  background: transparent;
  color: #f4d58d;
  font: inherit;
  font-size: 17px;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
}
.bh-spot__fill {
  position: absolute;
  inset: -2px;
  display: grid;
  place-items: center;
  border-radius: inherit;
  background: #f4d58d;
  color: #1b1300;
  pointer-events: none;
  clip-path: circle(0% at var(--bh-x, 50%) var(--bh-y, 50%));
}
.bh-spot:hover .bh-spot__fill,
.bh-spot:focus-visible .bh-spot__fill {
  clip-path: circle(150% at var(--bh-x, 50%) var(--bh-y, 50%));
}
.bh-spot:focus-visible {
  outline: 3px solid #f4d58d;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-spot__fill {
    transition: clip-path .5s cubic-bezier(.2, .7, .2, 1);
  }
}

Two-stripe skewed sweep

  • ::before / ::after
  • skewX()
  • transition-delay

Hover a button, or press Tab to focus it.

Two solid, skewed pseudo-element blocks cross the button one after the other, and the second one stays as the new fill. No gradient is involved: the angle comes from skewX() and the stagger from a 90ms delay that swaps sides on the way out. All three colors are light enough that the dark label passes 4.5:1 on each of them.

View code
.bh-stripe {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-height: 54px;
  padding: 0 32px;
  border: 0;
  border-radius: 10px;
  background: #ffd23f;
  color: #1d1a12;
  font: inherit;
  font-size: 16px;
  font-weight: 800;
  letter-spacing: .01em;
  cursor: pointer;
}
.bh-stripe::before,
.bh-stripe::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -20%;
  z-index: -1;
  width: 140%;
  transform: translateX(-110%) skewX(-24deg);
}
.bh-stripe::before {
  background: #ffb347;
}
.bh-stripe::after {
  background: #ff8a65;
}
.bh-stripe:hover::before,
.bh-stripe:hover::after,
.bh-stripe:focus-visible::before,
.bh-stripe:focus-visible::after {
  transform: translateX(0) skewX(-24deg);
}
.bh-stripe:focus-visible {
  outline: 3px solid #1d1a12;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-stripe::before {
    transition: transform .4s .09s cubic-bezier(.5, 0, .2, 1);
  }
  .bh-stripe::after {
    transition: transform .4s cubic-bezier(.5, 0, .2, 1);
  }
  .bh-stripe:hover::before,
  .bh-stripe:focus-visible::before {
    transition-delay: 0s;
  }
  .bh-stripe:hover::after,
  .bh-stripe:focus-visible::after {
    transition-delay: .09s;
  }
}

Ripple from the click point

  • createElement
  • custom properties
  • @keyframes

Hover, then click a button. Enter and Space ripple too.

Hover and focus darken the fill, and a click adds a short-lived circle at the pointer position, sized from the diagonal so it always reaches the far corner. The ink is darker than the button, so white text only gains contrast while it spreads. Keyboard presses ripple from the center, and the script skips the ripple entirely when reduced motion is on.

View code
.bh-ripple-bar {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  padding: 18px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
}
.bh-ripple {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-height: 48px;
  padding: 0 24px;
  border: 1px solid #0f766e;
  border-radius: 10px;
  background: #0f766e;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease;
}
.bh-ripple:hover,
.bh-ripple:focus-visible {
  border-color: #134e4a;
  background: #134e4a;
}
.bh-ripple--ghost {
  border-color: #cbd5e1;
  background: #fff;
  color: #0f172a;
}
.bh-ripple--ghost:hover,
.bh-ripple--ghost:focus-visible {
  border-color: #94a3b8;
  background: #f1f5f9;
}
.bh-ripple:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 3px;
}
.bh-ripple__ink {
  position: absolute;
  top: var(--bh-ry);
  left: var(--bh-rx);
  z-index: -1;
  width: var(--bh-rs);
  height: var(--bh-rs);
  margin: calc(var(--bh-rs) / -2) 0 0 calc(var(--bh-rs) / -2);
  border-radius: 50%;
  background: rgba(2, 44, 40, .45);
  pointer-events: none;
  transform: scale(0);
}
.bh-ripple--ghost .bh-ripple__ink {
  background: rgba(15, 118, 110, .16);
}
@media (prefers-reduced-motion: no-preference) {
  .bh-ripple__ink {
    animation: bh-ripple .6s ease-out forwards;
  }
}
@keyframes bh-ripple {
  to {
    opacity: 0;
    transform: scale(1);
  }
}

Borders and outlines

Edges that draw themselves, step outward or fill like a progress ring. None of them change the border width, so the button never changes size and the layout around it stays still.

Corner brackets that close the frame

  • ::before / ::after
  • width then height
  • transition-delay

Hover a button, or press Tab to focus it.

Two L-shaped pseudo-elements sit in opposite corners. On hover they grow across first and then down, so the frame seems to be drawn in two strokes, and the delays swap on the way out so it undraws in reverse. The text and the lines change color together, without any movement, so the reduced-motion version still shows a clear state.

View code
.bh-corner {
  position: relative;
  min-height: 54px;
  padding: 0 30px;
  border: 0;
  background: transparent;
  color: #1f2a44;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .02em;
  cursor: pointer;
  transition: color .25s ease;
}
.bh-corner::before,
.bh-corner::after {
  content: "";
  position: absolute;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  border: 0 solid currentColor;
}
.bh-corner::before {
  top: 0;
  left: 0;
  border-top-width: 2px;
  border-left-width: 2px;
}
.bh-corner::after {
  right: 0;
  bottom: 0;
  border-right-width: 2px;
  border-bottom-width: 2px;
}
.bh-corner:hover,
.bh-corner:focus-visible {
  color: #9a3412;
}
.bh-corner:hover::before,
.bh-corner:hover::after,
.bh-corner:focus-visible::before,
.bh-corner:focus-visible::after {
  width: 100%;
  height: 100%;
}
.bh-corner:focus-visible {
  outline: 2px solid #9a3412;
  outline-offset: 6px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-corner::before,
  .bh-corner::after {
    transition: height .2s ease, width .25s .18s ease;
  }
  .bh-corner:hover::before,
  .bh-corner:hover::after,
  .bh-corner:focus-visible::before,
  .bh-corner:focus-visible::after {
    transition: width .25s ease, height .2s .22s ease;
  }
}

Outline that steps out

  • outline-offset
  • custom properties
  • no layout shift

Hover a button, or press Tab to focus it.

A transparent outline already sits on the button at offset 0. Hover gives it a color and pushes it 5px out, so a second ring appears around the border without changing the box size. Outlines follow border-radius in current browsers, and the same ring doubles as the focus indicator, a little thicker for the keyboard.

View code
.bh-ring-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 18px;
}
.bh-ring {
  --bh-ring-c: #5b21b6;
  --bh-ring-tint: #ede9fe;
  min-height: 48px;
  padding: 0 22px;
  border: 2px solid var(--bh-ring-c);
  border-radius: 12px;
  outline: 2px solid transparent;
  outline-offset: 0;
  background: #fff;
  color: var(--bh-ring-c);
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease, outline-color .2s ease;
}
.bh-ring--danger {
  --bh-ring-c: #b91c1c;
  --bh-ring-tint: #fee2e2;
}
.bh-ring:hover,
.bh-ring:focus-visible {
  outline-color: var(--bh-ring-c);
  outline-offset: 5px;
  background: var(--bh-ring-tint);
}
.bh-ring:focus-visible {
  outline-width: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-ring {
    transition: background-color .2s ease, outline-color .2s ease, outline-offset .25s cubic-bezier(.3, .7, .2, 1);
  }
}

Border that fills like a progress ring

  • @property
  • hard-stop conic-gradient
  • inset box-shadow

Hover a button, or press Tab to focus it.

The border is a hard-stop conic-gradient painted under a transparent 3px border, and a huge inset box-shadow covers everything inside the padding box with white. Registering --bh-sweep as an angle with @property is what lets the stop animate from 0deg to 360deg, so the green ring draws around the pill once. Where @property is missing, the ring simply appears in full.

View code
@property --bh-sweep {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.bh-sweep {
  --bh-sweep: 0deg;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 54px;
  padding: 0 30px 0 24px;
  border: 3px solid transparent;
  border-radius: 999px;
  background: conic-gradient(#047857 var(--bh-sweep), #c9d4cf 0) border-box;
  box-shadow: inset 0 0 0 999px #fff;
  color: #064e3b;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
}
.bh-sweep svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bh-sweep:hover,
.bh-sweep:focus-visible {
  --bh-sweep: 360deg;
}
.bh-sweep:focus-visible {
  outline: 3px solid #047857;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-sweep {
    transition: --bh-sweep .7s cubic-bezier(.4, 0, .2, 1);
  }
}

Underline that grows from the center

  • scaleX()
  • transform-origin
  • text buttons

3 new comments on Homepage v4

Hover a button, or press Tab to focus it.

For low-emphasis text buttons, an underline is a stronger hover cue than a color shift alone. The line is a 2px pseudo-element scaled to zero on the X axis with its origin in the middle, so it opens outward from the center. The text color also deepens, which keeps a visible change for anyone with motion turned off.

View code
.bh-line-card {
  width: min(100%, 360px);
  padding: 20px 22px 12px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
}
.bh-line-card__title {
  margin: 0 0 6px;
  color: #1f2937;
  font-size: 15px;
  font-weight: 600;
}
.bh-line-card__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 22px;
}
.bh-line {
  position: relative;
  min-height: 44px;
  padding: 0 2px;
  border: 0;
  background: none;
  color: #1e3a8a;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: color .2s ease;
}
.bh-line::after {
  content: "";
  position: absolute;
  right: 2px;
  bottom: 9px;
  left: 2px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: center;
}
.bh-line:hover,
.bh-line:focus-visible {
  color: #9d174d;
}
.bh-line:hover::after,
.bh-line:focus-visible::after {
  transform: scaleX(1);
}
.bh-line:focus-visible {
  outline: 2px solid #9d174d;
  outline-offset: 2px;
  border-radius: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-line::after {
    transition: transform .3s cubic-bezier(.3, .7, .2, 1);
  }
}

Icon and text motion

Effects that move what is inside the button: an arrow that slides in, a label that swaps for file details, tracking that opens up, and a split button whose icon cell takes over. Each button reserves its width up front, so nothing nearby shifts.

Arrow that slides in

  • translateX()
  • opacity
  • reserved padding

Hover a button, or press Tab to focus it.

The arrow waits invisible inside the right padding, so the button is already the right width. On hover the label shifts left by 12px and the arrow fades in while sliding into place. With reduced motion, only the fade and the background change remain, and the reserved space keeps the two from overlapping.

View code
.bh-arrow {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 54px;
  padding: 0 38px;
  border: 0;
  border-radius: 12px;
  background: #111827;
  color: #fff;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bh-arrow__icon {
  position: absolute;
  top: 50%;
  right: 16px;
  margin-top: -9px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transition: opacity .2s ease;
}
.bh-arrow:hover,
.bh-arrow:focus-visible {
  background: #3730a3;
}
.bh-arrow:hover .bh-arrow__icon,
.bh-arrow:focus-visible .bh-arrow__icon {
  opacity: 1;
}
.bh-arrow:focus-visible {
  outline: 3px solid #3730a3;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-arrow__label {
    transition: transform .3s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-arrow__icon {
    transform: translateX(-10px);
    transition: opacity .2s ease, transform .3s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-arrow:hover .bh-arrow__label,
  .bh-arrow:focus-visible .bh-arrow__label {
    transform: translateX(-12px);
  }
  .bh-arrow:hover .bh-arrow__icon,
  .bh-arrow:focus-visible .bh-arrow__icon {
    transform: translateX(0);
  }
}

Label swap with a constant name

  • grid stacking
  • aria-describedby
  • translateY()

Hover a button, or press Tab to focus it.

Both labels share one grid cell, so the button is as wide as the longer one and never resizes. The file details are aria-hidden, so the accessible name stays Download report in every state, and aria-describedby still hands the size to screen reader users as a description. Hover rolls the details up into view; with reduced motion they crossfade.

View code
.bh-swap {
  display: inline-grid;
  overflow: hidden;
  min-height: 54px;
  padding: 0 30px;
  border: 0;
  border-radius: 10px;
  background: #be123c;
  color: #fff;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bh-swap__a,
.bh-swap__b {
  grid-area: 1 / 1;
  align-self: center;
  justify-self: center;
  white-space: nowrap;
  transition: opacity .2s ease;
}
.bh-swap__b {
  opacity: 0;
  font-variant-numeric: tabular-nums;
}
.bh-swap:hover,
.bh-swap:focus-visible {
  background: #9f1239;
}
.bh-swap:hover .bh-swap__a,
.bh-swap:focus-visible .bh-swap__a {
  opacity: 0;
}
.bh-swap:hover .bh-swap__b,
.bh-swap:focus-visible .bh-swap__b {
  opacity: 1;
}
.bh-swap:focus-visible {
  outline: 3px solid #9f1239;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-swap__a,
  .bh-swap__b {
    transition: opacity .25s ease, transform .3s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-swap__b {
    transform: translateY(32px);
  }
  .bh-swap:hover .bh-swap__a,
  .bh-swap:focus-visible .bh-swap__a {
    transform: translateY(-32px);
  }
  .bh-swap:hover .bh-swap__b,
  .bh-swap:focus-visible .bh-swap__b {
    transform: translateY(0);
  }
}

Letter spacing that opens up

  • letter-spacing
  • text-indent
  • fixed inline-size

Osteria Lume

Dinner from 6 pm, Tuesday to Sunday

Hover a button, or press Tab to focus it.

Letter spacing is normally a layout change, so the button gets a fixed inline size wide enough for the open state and the label spreads inside it without nudging anything. Spacing is added after every letter, including the last, so a matching text-indent keeps the word optically centered. The fill flips to cream in the same moment.

View code
.bh-track-hero {
  display: grid;
  justify-items: center;
  gap: 6px;
  text-align: center;
}
.bh-track-hero__name {
  margin: 0;
  color: #f5f0e8;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(30px, 8vw, 40px);
  font-weight: 600;
  line-height: 1.1;
}
.bh-track-hero__meta {
  margin: 0 0 18px;
  color: #c8c1b6;
  font-size: 14px;
}
.bh-track {
  inline-size: min(100%, 250px);
  min-height: 54px;
  padding: 0 12px;
  border: 1px solid #f5f0e8;
  border-radius: 2px;
  background: transparent;
  color: #f5f0e8;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .08em;
  text-indent: .08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background-color .25s ease, color .25s ease;
}
.bh-track:hover,
.bh-track:focus-visible {
  background: #f5f0e8;
  color: #1c1917;
  letter-spacing: .24em;
  text-indent: .24em;
}
.bh-track:focus-visible {
  outline: 2px solid #f5f0e8;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-track {
    transition: background-color .25s ease, color .25s ease, letter-spacing .4s cubic-bezier(.3, .7, .2, 1), text-indent .4s cubic-bezier(.3, .7, .2, 1);
  }
}

Split button with an icon cell that takes over

  • clip-path: inset()
  • calc()
  • rotate

Hover a button, or press Tab to focus it.

The indigo icon cell is really a full-size overlay clipped to its last 48px. Hover opens the clip to the whole button while the plus rotates a quarter turn, and because the overlay holds its own white copy of the label, the text flips color exactly where the indigo edge passes. The label is read once, since the overlay is aria-hidden.

View code
.bh-split {
  position: relative;
  display: inline-flex;
  align-items: stretch;
  min-height: 52px;
  padding: 0;
  border: 2px solid #4338ca;
  border-radius: 12px;
  background: #fff;
  color: #312e81;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
}
.bh-split__label {
  display: flex;
  align-items: center;
  padding: 0 20px 0 22px;
  white-space: nowrap;
}
.bh-split__cell {
  display: grid;
  place-items: center;
  flex: 0 0 48px;
}
.bh-split__fill {
  position: absolute;
  inset: 0;
  display: flex;
  border-radius: 10px;
  background: #4338ca;
  color: #fff;
  pointer-events: none;
  clip-path: inset(0 0 0 calc(100% - 48px));
}
.bh-split__fill svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
}
.bh-split:hover .bh-split__fill,
.bh-split:focus-visible .bh-split__fill {
  clip-path: inset(0);
}
.bh-split:focus-visible {
  outline: 3px solid #4338ca;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-split__fill {
    transition: clip-path .4s cubic-bezier(.6, 0, .2, 1);
  }
  .bh-split__fill svg {
    transition: rotate .4s cubic-bezier(.6, 0, .2, 1);
  }
  .bh-split:hover .bh-split__fill svg,
  .bh-split:focus-visible .bh-split__fill svg {
    rotate: 90deg;
  }
}

Depth, press and theme

Buttons that feel physical: a chunky key that sinks when pressed, a magnetic pull you opt into per element, a lift whose shadow is drawn once and faded in, and one rule that serves light and dark themes.

Chunky 3D press

  • hard box-shadow
  • translateY()
  • :active

Hover, then press and hold a button.

The solid shadow with no blur is the side of the key. Hover raises the key 2px and lengthens the side to match, and :active, which comes last in the cascade, pushes it all the way down so the shadow disappears. Shadow and transform move in step, so the bottom edge of the key stays put and only the top travels.

View code
.bh-press-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 18px;
  padding-bottom: 8px;
}
.bh-press {
  --bh-press-side: #1a0b00;
  min-height: 56px;
  padding: 0 28px;
  border: 2px solid var(--bh-press-side);
  border-radius: 14px;
  background: #ff7a1a;
  color: #1a0b00;
  font: inherit;
  font-size: 16px;
  font-weight: 800;
  box-shadow: 0 6px 0 var(--bh-press-side);
  cursor: pointer;
  transition: background-color .15s ease;
}
.bh-press--alt {
  background: #5eead4;
}
.bh-press:hover,
.bh-press:focus-visible {
  background: #ff9445;
  box-shadow: 0 8px 0 var(--bh-press-side);
}
.bh-press--alt:hover,
.bh-press--alt:focus-visible {
  background: #99f6e4;
}
.bh-press:focus-visible {
  outline: 3px solid #1a0b00;
  outline-offset: 4px;
}
.bh-press:active {
  background: #f26a00;
  box-shadow: 0 2px 0 var(--bh-press-side);
}
.bh-press--alt:active {
  background: #2dd4bf;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-press {
    transition: background-color .15s ease, transform .12s ease, box-shadow .12s ease;
  }
  .bh-press:hover,
  .bh-press:focus-visible {
    transform: translateY(-2px);
  }
  .bh-press:active {
    transform: translateY(6px);
    box-shadow: 0 0 0 var(--bh-press-side);
  }
}

Magnetic pull, opt-in

  • data attribute opt-in
  • pointerType
  • individual scale

Move the mouse around the button, or press Tab to focus it.

Only elements with data-bh-magnetic get the effect, and only for a mouse with reduced motion off. The script writes a clamped offset to two custom properties on the zone around the button; the button follows by up to 14px and the label by half again, which gives the parallax. Keyboard focus gets the same color change and a small scale instead.

View code
.bh-mag {
  display: grid;
  place-items: center;
  padding: 36px;
  border-radius: 50%;
}
.bh-mag__btn {
  display: grid;
  place-items: center;
  width: 140px;
  height: 140px;
  padding: 16px;
  border: 0;
  border-radius: 50%;
  background: #c6f432;
  color: #111;
  font: inherit;
  font-size: 16px;
  font-weight: 800;
  line-height: 1.2;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bh-mag__label {
  display: block;
}
.bh-mag__btn:hover,
.bh-mag__btn:focus-visible {
  background: #e4ff8a;
}
.bh-mag__btn:focus-visible {
  outline: 3px solid #c6f432;
  outline-offset: 5px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-mag__btn {
    transform: translate(var(--bh-mx, 0px), var(--bh-my, 0px));
    transition: background-color .2s ease, transform .35s cubic-bezier(.2, .8, .2, 1), scale .3s cubic-bezier(.2, .8, .2, 1);
  }
  .bh-mag__label {
    transform: translate(calc(var(--bh-mx, 0px) * .5), calc(var(--bh-my, 0px) * .5));
    transition: transform .35s cubic-bezier(.2, .8, .2, 1);
  }
  .bh-mag__btn:hover,
  .bh-mag__btn:focus-visible {
    scale: 1.06;
  }
}

Lift with a pre-drawn shadow

  • opacity on ::after
  • stationary wrapper
  • translateY()

Pro

$12 per seat / month

Unlimited projects, version history and client sharing.

Hover a button, or press Tab to focus it.

The deep shadow is painted once on a pseudo-element and only its opacity animates, which is far cheaper than transitioning a large blur. The :hover rule sits on a wrapper that never moves, so the lifted button cannot slide out from under the pointer and flicker at its bottom edge. Keyboard focus lifts the button directly.

View code
.bh-lift-card {
  width: min(100%, 300px);
  padding: 24px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(15, 23, 42, .08);
}
.bh-lift-card__plan {
  margin: 0;
  color: #1d4ed8;
  font-size: 14px;
  font-weight: 700;
}
.bh-lift-card__price {
  margin: 4px 0 8px;
  color: #0f172a;
  font-size: 34px;
  font-weight: 700;
  line-height: 1.1;
}
.bh-lift-card__price span {
  color: #475569;
  font-size: 14px;
  font-weight: 500;
}
.bh-lift-card__copy {
  margin: 0 0 20px;
  color: #475569;
  font-size: 14px;
  line-height: 1.5;
}
.bh-lift {
  display: block;
}
.bh-lift__btn {
  position: relative;
  width: 100%;
  min-height: 50px;
  padding: 0 20px;
  border: 0;
  border-radius: 12px;
  background: #2563eb;
  color: #fff;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease;
}
.bh-lift__btn::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 16px 28px -10px rgba(29, 78, 216, .6);
  opacity: 0;
  transition: opacity .25s ease;
}
.bh-lift:hover .bh-lift__btn,
.bh-lift__btn:focus-visible {
  background: #1d4ed8;
}
.bh-lift:hover .bh-lift__btn::after,
.bh-lift__btn:focus-visible::after {
  opacity: 1;
}
.bh-lift__btn:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-lift__btn {
    transition: background-color .2s ease, transform .25s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-lift:hover .bh-lift__btn,
  .bh-lift__btn:focus-visible {
    transform: translateY(-3px);
  }
}

One ghost button for light and dark

  • light-dark()
  • color-scheme
  • @supports fallback

Light theme

Dark theme

Hover a button, or press Tab to focus it.

The button only knows two tokens, ink and paper, and hover swaps them. Each panel sets color-scheme, and where light-dark() is supported the tokens resolve from it, so the same rule serves both themes; older browsers use the per-panel values. Both pairings were picked to pass 4.5:1, and the calendar icon nudges right on hover.

View code
.bh-theme {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  width: min(100%, 460px);
}
.bh-theme__panel {
  display: grid;
  flex: 1 1 160px;
  justify-items: center;
  gap: 12px;
  padding: 22px 12px;
  border-radius: 16px;
  background: var(--bh-th-paper);
  color: var(--bh-th-muted);
}
.bh-theme__panel--light {
  color-scheme: light;
  --bh-th-ink: #1e40af;
  --bh-th-paper: #fff;
  --bh-th-muted: #475569;
}
.bh-theme__panel--dark {
  color-scheme: dark;
  --bh-th-ink: #93c5fd;
  --bh-th-paper: #0f172a;
  --bh-th-muted: #cbd5e1;
}
@supports (color: light-dark(#000, #fff)) {
  .bh-theme__panel {
    --bh-th-ink: light-dark(#1e40af, #93c5fd);
    --bh-th-paper: light-dark(#fff, #0f172a);
    --bh-th-muted: light-dark(#475569, #cbd5e1);
  }
}
.bh-theme__panel p {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
}
.bh-theme__btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 46px;
  padding: 0 18px;
  border: 2px solid var(--bh-th-ink);
  border-radius: 10px;
  background: transparent;
  color: var(--bh-th-ink);
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.bh-theme__btn svg {
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}
.bh-theme__btn:hover,
.bh-theme__btn:focus-visible {
  background: var(--bh-th-ink);
  color: var(--bh-th-paper);
}
.bh-theme__btn:focus-visible {
  outline: 3px solid var(--bh-th-ink);
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .bh-theme__btn svg {
    transition: transform .25s cubic-bezier(.3, .7, .2, 1);
  }
  .bh-theme__btn:hover svg,
  .bh-theme__btn:focus-visible svg {
    transform: translateX(3px);
  }
}

How do you add a hover effect to a CSS button?

Write a base rule for the button, add a :hover rule that changes only the properties you care about, and put a transition on the base rule so the change animates both ways.

That’s the whole mechanism, and every effect further down is a variation on it.

  1. Style the resting state with background color, text color, padding and border radius.
  2. Put the transition on that same rule and name each property you plan to change.
  3. Add a :hover rule containing only the values that differ.
  4. Test it with a mouse, then again with the Tab key.
.btn {
  background-color: #1d4ed8;
  color: #fff;
  transition: background-color 200ms ease-out, transform 200ms ease-out;
}

.btn:hover {
  background-color: #1e40af;
  transform: translateY(-2px);
}

The transition belongs on the base rule, not on :hover. If it only lives in the hover rule, the button eases in and then snaps back on exit, because the resting state has nothing to animate with.

A lot of developers hit this one when they copy a snippet and move a line.

web.dev’s CSS learning module describes the transition shorthand as property, duration, timing function and delay. The property is whatever should animate, like background-color or transform, and you separate several with commas. The duration is the first time value in the shorthand, per MDN Web Docs. For the timing function you pick ease-out, ease-in-out or a custom cubic-bezier() curve, and the delay is the second time value. Buttons rarely need a delay.

Name the properties instead of writing transition: all. The all keyword also animates anything that changes later, like a width set by a media query, and you end up with motion nobody planned.

How long should a hover transition last?

150ms to 300ms suits most buttons. Under that range it reads as a flicker, and past it the button starts to feel like it’s trailing the pointer.

Material Design’s motion guidance says to scale duration with the distance an element travels and its size, not to fix one value for everything. A button that only changes color moves nowhere, so it sits at the fast end.

For hover, ease-out usually feels more responsive than ease-in-out, since the change starts at full speed the moment the pointer arrives.

Transition or @keyframes for hover effects?

Use a transition for a two-state change and @keyframes for anything with several steps or a loop, like a pulse.

A transition reverses smoothly if the pointer leaves halfway. An animation attached to :hover has no way back. When hover ends, the animation is removed and the button jumps to its resting style.

That’s why simple color and lift effects stay on transitions, while looping effects built with keyframe animations need a plan for the exit.

Which states should a button hover style be paired with?

Pair every :hover rule with :focus-visible, and give :active its own look.

Hover responds to a pointer resting over the button, so it only exists for mouse and trackpad users. :focus-visible covers keyboard focus, and :active covers the press itself.

Plain :focus fires on every focus, including mouse clicks. According to the Chromium blog (2020), :focus-visible runs the same heuristic the browser uses to decide whether to draw its default focus ring, so keyboard users get the style and mouse users don’t get a ring after every click.

W3C’s WCAG 2.2 Technique C45 recommends exactly this selector for keyboard focus indication, and ties it to success criterion 2.4.7, Focus Visible.

.btn:hover,
.btn:focus-visible {
  background-color: #1e40af;
}

.btn:active {
  transform: translateY(0);
}

Order matters here. :hover and :active have equal specificity, so :active goes last or the pressed style never shows.

Disabled buttons

A disabled button still matches :hover. Scope the effect with .btn:not(:disabled):hover, or the dead button lifts and glows like a live one.

Cursor

Buttons keep the default arrow cursor unless you set cursor: pointer. Many teams add it to signal clickability, but it’s a design choice, not a requirement.

Safe properties to animate on hover

web.dev’s guidance on compositor-only properties names transform and opacity as the two properties the compositor can animate without triggering layout or paint.

PropertyPipeline costTypical hover use
transformComposite onlyLift, scale, fill slide
opacityComposite onlyFade a pseudo-element overlay
background-color, color, border-colorPaintColor shift
box-shadowPaintDepth, glow
width, height, padding, marginLayout and paintAvoid on hover

Paint-level properties are fine on a single button. Layout properties are where the trouble starts, since they move neighboring elements and can shift the whole page when someone hovers.

Box-shadow is the usual paint-cost driver, because large blur radii tend to take longer to paint. Keep the radius modest, or draw the big shadow once on a pseudo-element and fade its opacity. A box shadow generator helps you settle the values before they reach the stylesheet.

Stopping layout shift on hover

  • Keep border width constant and change only border-color.
  • If the hover state adds a border, start with a 2px transparent one.
  • For a thicker edge, use box-shadow: inset 0 0 0 2px or outline.
  • To grow a button, use transform: scale() and leave width and height alone.

Does will-change help?

The sources disagree. web.dev’s guide recommends promoting animated elements with will-change, while MDN calls it a last resort for performance problems that already exist, and warns that values like opacity create a stacking context up front.

For one hovered button, skip it. Add it only if you can see jank.

To check, open the Rendering panel in Chrome DevTools, turn on Paint flashing, and hover the button. A green flash marks every repainted region.

Hover effect techniques for buttons

Most hover effects on buttons are a variation on one of the techniques below.

Color shift

Change background-color, color or border-color and leave everything else alone.

It’s the cheapest effect, the most readable, and the easiest to keep accessible. It stops working when the hover color sits too close to the resting color, because the change disappears for anyone with low vision.

Lift with translateY and box-shadow

A lift pairs transform: translateY(-2px) with a deeper shadow. The transform is cheap and the shadow repaints.

Two things break it: a parent with overflow: hidden that clips the shadow, and a button sitting flush against its neighbor.

The shadow cost has a workaround. Put the larger shadow on a pseudo-element and transition its opacity.

Scale

A transform: scale(1.05) grows the button without moving anything else in the layout.

That also means it can overlap the elements beside it.

  • Keep the factor small, around 1.03 to 1.08.
  • Text can look soft mid-transition in some browsers.

Glow

A glow is a zero-offset box-shadow in the button’s own accent color. It reads well on dark backgrounds and is nearly invisible on white, where a shadow just looks like dirt.

It’s a paint-heavy effect, so keep it for one prominent button per view.

Gradient position shift

Gradients cannot be transitioned directly, so make the gradient twice as wide as the button and slide background-position instead.

.btn {
  background-image: linear-gradient(90deg, #1d4ed8, #7c3aed);
  background-size: 200% 100%;
  background-position: 0 0;
  transition: background-position 400ms ease-out;
}

.btn:hover {
  background-position: 100% 0;
}

Picking the stops is the slow part, and a gradient generator shortens it. Registered custom properties can animate the stops themselves, but the position trick works in every browser.

Fill slide with ::before

A pseudo-element holds the hover color and scales in from one edge, so the fill moves on transform and never touches layout.

.btn {
  position: relative;
  overflow: hidden;
  z-index: 0;
}

.btn::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: #1e40af;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 300ms ease-out;
}

.btn:hover::before {
  transform: scaleX(1);
}

The button needs position: relative and overflow: hidden for this to work. Set transform-origin to choose the direction, and give the text color its own transition, timed to match.

Border draw

The simple version transitions outline-offset or an inset box-shadow. A true drawing effect, where the line traces the button, needs pseudo-elements or SVG, and the patterns in border animation examples show how.

It usually goes one of two ways: an outline that grows outward on hover, or four gradient lines whose background-size animates in sequence.

The traced version is the heaviest effect here. Skip it on touch-first layouts.

Clip-path reveal

Transition clip-path on a pseudo-element from inset(0 100% 0 0) to inset(0) and the fill wipes in with a hard edge.

The shape function must match at both ends, with the same type and point count, or the browser cannot interpolate and the change snaps. It usually repaints on every frame, so a plain fill slide on transform is cheaper when you don’t need the shaped edge.

Choosing a hover effect by button type

Pick the simplest effect that makes the button look clickable. For most primary buttons that means a color shift, with a small lift only if the rest of the interface already uses elevation.

TechniqueBest fitStrengthMain risk
Color shiftPrimary, secondaryCheap, clearLow contrast between states
LiftCards with CTAsTactile feedbackClipped shadow, crowding
Fill slideGhost, outlineVisible directionLabel contrast mid-animation
Gradient shiftHero buttonsBrand characterExtra paint, off-brand fast
GlowDark UIsDraws the eyeInvisible on light backgrounds

A call-to-action button carries the one task the page wants done, so the hover should confirm it quickly and stay out of the way. A plain color shift does that.

On a primary button I’d stay with the color shift and maybe add a 2px lift. A secondary button should get a lighter shift than the primary. Ghost buttons already start as an outline, so a fill slide or a border color change suits them. Icon-only buttons have no label to act on, which rules out text-based effects, and a background tint or a scale works better there.

The fill slide is the effect I would steer people away from on a primary button. The label has to pass contrast against both the resting and the filled background, and halfway through the wipe it often passes against neither.

For more button styles to pair with these effects, a set of cool CSS buttons gives you starting points to adapt.

Why hover effects stick on touch screens

Touch screens have no true hover, so browsers emulate it on tap, and the emulated state stays on the element until the user taps somewhere else.

The Tailwind Labs pull request that added a fix for this describes it the same way, with a tapped hover style persisting on mobile until the next tap elsewhere.

Guarding hover styles with a media query

Wrap the hover rule in a hover-capability media query, and leave the keyboard rules outside it.

.btn:focus-visible {
  background-color: #1e40af;
}

@media (hover: hover) and (pointer: fine) {
  .btn:hover {
    background-color: #1e40af;
    transform: translateY(-2px);
  }
}

Which query to use depends on what you want hybrid devices (touch laptops, tablets with a mouse) to do.

QueryTrue whenCatch
(hover: hover)Primary input can hoverSome browsers report a hovering primary input on touch screens
(hover: hover) and (pointer: fine)Primary input hovers and is preciseExcludes hybrids where the mouse is secondary
(any-hover: hover)Any attached input can hoverAlso matches while the user is tapping

Sources disagree on how reliable the plain hover query is. Michelle Barker’s write-up notes that on some Android versions a long press emulates hover, so the query evaluates true, which is why she adds pointer: fine.

Some developers in CSS Working Group discussions go further and say browsers can report a mouse-like primary input even on touch screens. And a WebKit bug report filed against iOS 13.4 documents the reverse failure, where a connected mouse did not match hover or pointer: fine at all.

MDN lists any-hover as widely available, supported across browsers since December 2018. The query has been usable for years, and the edge cases come from what devices report.

Whatever you choose, nothing a user needs should exist only in the hover state. The query removes the effect on touch, so the tap has to work without it.

Tailwind CSS hover variant behavior

Tailwind CSS added the opt-in hoverOnlyWhenSupported flag in v3.1, and its hover: variant only applies on hover-capable devices by default in v4.

Tailwind Labs flagged that as a breaking change, because some sites rely on hover styles firing on tap, such as menus that open on hover. A one-off exception is possible with an arbitrary variant.

If a Tailwind site has a hover-only menu, it breaks on upgrade. A hover effect on a button does not.

Accessibility requirements for hover effects

W3C’s WCAG 2.2 asks for 4.5:1 contrast on normal button text (success criterion 1.4.3) and 3:1 on the visual information that identifies a component and its states (1.4.11).

In practice the hover text needs 4.5:1 against the hover background, not only the resting one, while boundaries and the cues for states such as focus or selection need 3:1 against adjacent colors.

Inactive components are exempt from 1.4.11, which is why a grayed-out disabled button can fail it legally.

Run the hover colors through a color contrast check before they ship. A hover shade that darkens the fill can improve the text ratio and wreck the boundary ratio in the same step.

Passing the ratios doesn’t guarantee a visible hover. The criterion measures contrast against adjacent colors, not between a component’s own states, and W3C’s Understanding document for 1.4.11 says color changes between states don’t have to reach 3:1.

So a hover shade only slightly darker than the resting one can pass and still look like one flat area to some people. Add a second cue such as an underline or a border so the change doesn’t depend on color alone.

An accessible color palette generator is a quick way to find hover shades that pass against the button’s own background.

Reduced motion

MDN names scaling or panning large objects as triggers for people with vestibular disorders. A small button scale is a mild case, but dropping it costs nothing.

@media (prefers-reduced-motion: reduce) {
  .btn {
    transition-property: background-color, color;
  }

  .btn:hover {
    transform: none;
  }
}

The color fade stays and the motion goes.

WebKit’s post introducing the feature describes Apple.com using it to disable a scaling effect. To test your own, Chrome DevTools can emulate the setting from the Rendering tab.

Managing hover styles with custom properties, Tailwind CSS and Bootstrap

Store the hover color, duration and easing in custom properties, and every button on the site changes from one place.

:root {
  --btn-bg: #1d4ed8;
  --btn-bg-hover: #1e40af;
  --btn-speed: 200ms;
  --btn-ease: ease-out;
}

.btn {
  background-color: var(--btn-bg);
  transition: background-color var(--btn-speed) var(--btn-ease);
}

.btn:hover {
  background-color: var(--btn-bg-hover);
}

The transition still runs because the computed background-color changes. A dark theme only redefines the variables, and the hover rules stay untouched.

Bootstrap’s own blog (2022) says v5.2.0-beta1 added CSS variables to all its components, buttons included.

The hover and active states have their own variables, --bs-btn-hover-bg, --bs-btn-hover-color, --bs-btn-hover-border-color and --bs-btn-active-bg. The custom button example in Bootstrap’s button docs shades the hover background by 10% and the active background by 20%.

.btn-brand {
  --bs-btn-bg: #7c3aed;
  --bs-btn-border-color: #7c3aed;
  --bs-btn-hover-bg: #6d28d9;
  --bs-btn-hover-border-color: #6d28d9;
  --bs-btn-active-bg: #5b21b6;
}

Bootstrap’s .btn rule declares its own transition list. If you add a transform lift, extend that list in your override or the lift snaps.

Load your stylesheet after Bootstrap, or import Bootstrap into a cascade layer so unlayered rules win without raising specificity. A guide to overriding Bootstrap CSS covers the common routes.

Tailwind CSS handles all of this with utility classes.

  • transition-colors duration-200 ease-out sets up the transition.
  • hover:, focus-visible: and active: cover the states.
  • motion-safe: and motion-reduce: respond to the motion preference.
  • group-hover: reaches child elements.
<button class="bg-blue-700 text-white transition-colors duration-200 ease-out hover:bg-blue-800 focus-visible:bg-blue-800 active:bg-blue-900">Save</button>

Conflicts between utilities are decided by their order in the generated stylesheet, not by their order in the class attribute. More Tailwind button patterns show how the variants combine.

Why a button hover effect is not working

Most dead hover effects come from a competing rule or a property that can’t animate, and now and then from something blocking the pointer. Force the state in DevTools first, because the Styles pane then shows which rule is winning.

  1. Right-click the button in the Elements panel and choose Force element state, or use the toggle in the Styles pane. Chrome for Developers lists :hover, :focus, :active and :focus-visible among the states you can force.
  2. Look for a crossed-out rule. If your hover declaration is struck through, another selector outranks it.
  3. Check for inline styles. A style attribute, often written by a script or a page-builder plugin, beats any stylesheet rule.
  4. Check the property itself. By default, display does not transition, and neither does a change to or from height: auto. Newer opt-ins (transition-behavior: allow-discrete and interpolate-size: allow-keywords) cover both, but only in browsers that support them.
  5. Look for an overlay. pointer-events: none on the button, or another element stacked on top, means the pointer never reaches it.
  6. Rule out stale CSS with a hard reload or a cleared build cache. A rule added to a cached file will not appear.
  7. If the effect works on desktop and is missing on a phone, the touch guard from earlier is doing its job.

Specificity is the most common culprit in step two. An ID-based rule such as #cta .btn outranks .btn:hover, and the hover never shows.

Lower the competing selector instead of raising yours. A CSS specificity calculator scores both selectors, so you can see which one wins before reaching for !important.

CSS Button Hover Effects FAQ

Can JavaScript replace CSS for hover effects?

Only for effects CSS cannot express, such as a highlight that follows the cursor or a ripple that starts at the click point.

Color shifts, lifts, scales and fills run in pure CSS without a script, so reach for JavaScript last.

How do you change a button’s text or icon on hover?

Text content cannot be animated, so stack two elements, or set a pseudo-element’s content in the hover rule, and swap between them.

Fade or slide the swap with opacity and transform, and reserve the button’s width so the label change does not shift the layout.

Do hover effects work on links styled as buttons?

Yes. :hover applies to any element, so the same rules work on an anchor that carries the button class.

Use a real button element for actions and a link for navigation, since the two behave differently with keyboards and assistive technology.

Why does a button flicker when it lifts on hover?

The lift moves the button out from under the pointer near its bottom edge, so the hover state drops, the button falls back, and the cycle repeats.

Put the :hover rule on a stationary wrapper and apply the transform to the child, or move the lift to a pseudo-element.

How do you delay a hover effect?

Add transition-delay to the :hover rule. The destination state’s rule decides the timing, so only the entry is delayed.

The exit still follows the delay on the base rule, which is none unless you set one.

What to Test Before Shipping CSS Button Hover Effects

A hover style that passes with the mouse alone is unfinished. Test with the keyboard first, because a missing focus style is a conformance failure. Touch comes next, since a stuck hover is visible on every tap, and the reduced-motion override is the cheapest fix of the lot at one media block.

Each check removes something. The hover guard drops the effect on hybrid devices that report touch as the primary input, and the reduced-motion block drops the lift.

Accept both losses. A plainer button costs less than a broken one.

The same transform and shadow techniques carry over to card hover effects and the wider set of CSS hover effects, where the touch guard matters just as much.