CSS Button States, Done Properly: Hover, Focus, Active, and Disabled

A button isn't one style — it's five. Rest, hover, focus, active (mid-click), and disabled all need distinct, deliberate treatment, and most CSS tutorials only ever show the rest state in a screenshot. Skip the other four and you get buttons that look great in a design file and feel unresponsive or confusing the moment someone actually uses the site — no feedback on hover, no visible focus ring for keyboard users, and a disabled state that's indistinguishable from an active one.

The Base Button

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 12px 24px;
  border-radius: 10px;
  border: none;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  background: var(--purple);
  color: #06080f;
  transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease;
}

display: inline-flex rather than plain inline-block is worth defaulting to — it lets you drop an icon next to the label later (gap handles the spacing) without restructuring the button's CSS.

Hover: Small Movement Reads Better Than Big Color Shifts

.btn:hover {
  background: #b79cfb;
  transform: translateY(-1px);
  box-shadow: 0 4px 14px rgba(167,139,250,0.35);
}

A 1px lift plus a soft shadow communicates "clickable" more convincingly than a big color jump, and it's subtle enough not to feel gimmicky across dozens of buttons on one page.

Focus-Visible: Not the Same as Focus

Older CSS advice says "never remove outline," which led to bad clunky rings appearing even on mouse clicks. :focus-visible solves the actual problem — it targets keyboard/assistive-tech focus specifically, while staying invisible on a mouse click:

.btn:focus-visible {
  outline: 2px solid var(--blue);
  outline-offset: 2px;
}
.btn:focus:not(:focus-visible) {
  outline: none;
}

This is not optional polish — WCAG 2.1 requires a visible focus indicator, and browsers' :focus-visible heuristics (show it for keyboard, suppress for pointer) now make it possible to satisfy that requirement without the old "outline flashes on every click" complaint.

Active State: The Split-Second That Confirms a Click Registered

.btn:active {
  transform: translateY(0) scale(0.98);
  box-shadow: 0 2px 6px rgba(167,139,250,0.3);
}

A slight scale-down on :active gives near-instant tactile confirmation that a click was registered — this matters more on trackpads and touchscreens than a mouse, where there's no physical click feedback at all.

Button States, Already Handled

Every button in UIXDraft's 180+ template bundle ships with hover, focus-visible, active, and disabled states pre-built — swap the color variable and move on.

Browse the templates →

Disabled: Don't Just Lower the Opacity

A common shortcut is opacity: 0.5 on a disabled button — visually plausible, but it fails to communicate "this can't be clicked" to a screen reader unless the underlying element is also actually disabled. Pair the visual treatment with the real HTML attribute:

<button class="btn" disabled>Processing…</button>

<style>
.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  box-shadow: none;
  transform: none;
}
</style>

The native disabled attribute removes the button from the tab order and announces its state to assistive tech automatically — CSS alone (e.g. a .is-disabled class with pointer-events: none) doesn't do either of those things.

Button Groups: Shared Borders Without Doubling Them

A segmented control (like a toggle between "Monthly" / "Yearly") built from adjacent buttons tends to double up borders at shared edges unless you collapse them deliberately:

.btn-group {
  display: inline-flex;
}
.btn-group .btn {
  border-radius: 0;
  border-right: 1px solid rgba(0,0,0,0.15);
}
.btn-group .btn:first-child {
  border-radius: 10px 0 0 10px;
}
.btn-group .btn:last-child {
  border-radius: 0 10px 10px 0;
  border-right: none;
}

Only rounding the outer corners of the first and last button, and removing the trailing border on the last one, keeps the group reading as a single cohesive control rather than a row of separate buttons glued together.

Gradient and Outline Variants

.btn-gradient {
  background: linear-gradient(135deg, #a78bfa, #38bdf8);
  color: #06080f;
}

.btn-outline {
  background: transparent;
  border: 1.5px solid var(--purple);
  color: var(--purple);
}
.btn-outline:hover {
  background: rgba(167,139,250,0.1);
}

An outline button's hover state needs its own background tint — without one, hovering an outline button produces no visible feedback at all, since there's no fill color to darken or lighten.

Icon Buttons Need Their Own Accessible Label

A button with only an icon inside — no visible text — is invisible to screen readers unless it's labeled explicitly. This gets missed constantly on close buttons, menu triggers, and social icons:

<button class="btn-icon" aria-label="Close dialog">
  <svg aria-hidden="true" ...>...</svg>
</button>

<style>
.btn-icon {
  width: 40px;
  height: 40px;
  padding: 0;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
</style>

aria-hidden="true" on the SVG stops assistive tech from trying to describe the icon's raw path data, while aria-label on the button gives it a real name. Without both, a screen reader either announces nothing useful or reads out meaningless SVG markup.

A Contained Ripple Effect With No JavaScript Framework

A radial "ripple" on click is normally associated with JS-heavy component libraries, but a version of it is achievable with a pseudo-element and a scale transition triggered on :active:

.btn-ripple {
  position: relative;
  overflow: hidden;
}
.btn-ripple::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle, rgba(255,255,255,0.35) 0%, transparent 70%);
  opacity: 0;
  transform: scale(0.6);
  transition: opacity 400ms ease, transform 400ms ease;
}
.btn-ripple:active::after {
  opacity: 1;
  transform: scale(1.4);
  transition: none;
}

It's a simplified version — a true Material-style ripple tracks the exact click coordinates via JS — but for most buttons this CSS-only approximation is close enough and costs nothing in bundle size.

Button Sizing and Touch Targets

SizePaddingMin heightUse case
Small8px 14px32pxTable row actions, compact UI
Default12px 24px44pxForms, primary CTAs
Large16px 32px52pxHero section CTAs

44px is the commonly cited minimum touch target height (from Apple's and Google's own accessibility guidelines) — going smaller on a button that's meant to be tapped on mobile measurably increases mis-taps.

Frequently Asked Questions

Should a button ever be an anchor tag instead of <button>?

Use <a> when the action navigates to a new URL, and <button> when it triggers behavior on the current page (opening a modal, submitting a form). Styling both identically as ".btn" is fine — the distinction is about what happens on click, not appearance, and getting it right matters for keyboard and screen reader behavior. A common bug is an <a> with no href used purely for its click handler — it silently drops out of the tab order and stops behaving like a link at all.

Why does my button's hover animation feel laggy on mobile?

Mobile browsers simulate hover on tap, which can produce a stuck or delayed hover state after the finger lifts. For touch devices, lean on the :active state for feedback instead, and consider wrapping hover-only effects in an @media (hover: hover) query so touch devices skip them entirely.

How do I style a loading state on a submit button?

Keep the button's dimensions fixed so it doesn't resize when the label changes to a spinner, disable it during the request to prevent double-submits, and use aria-busy="true" so screen readers announce the pending state rather than silence. A CSS-only spinner (a rotating border on a pseudo-element) is usually enough — save an actual image asset or icon font for it. Restoring focus to the button after the request completes also matters if the disabled state temporarily removed it from the tab order.