CSS Button States: Hover, Focus, Active, Disabled, Outline and Icon Buttons | How-to Guide

CSS button states are the styles a button shows when it is hovered (:hover), focused by keyboard (:focus-visible), pressed (:active), disabled (:disabled or aria-disabled) or loading. Here is one button in every state, with copyable plain CSS and Tailwind, plus outline, ghost, close and icon buttons that stay accessible.

A button has six states worth designing: default, hover, focus-visible, active, disabled and loading. Most snippets online show one or two of them, on different buttons, with different code. This guide uses one button throughout, shows every state side by side, and gives copyable plain CSS and Tailwind for each. It then covers the variants people search for most (outline, ghost, close, icon) and the difference between disabled and aria-disabled, which is more than a styling choice.

The state matrix: one button, six states

Each tile below is drawn in one state so you can compare them at a glance. The live button under the grid lets you force a state or try it with your own mouse and keyboard.

Save changes
Default. Resting state. White on blue-600 (#2563eb) is about 5.2:1 contrast.
Save changes
Hover. Pointer over it. One step darker; no layout change.
Save changes
Focus-visible. Reached by keyboard. A 2px ring with a 2px gap so it reads on any background.
Save changes
Active. While pressed. Darker again and nudged down 1px.
Save changes
Disabled. Not available. Faded, no hover, no pointer.
Saving
Loading. Working. Spinner plus a verb that says what is happening.
StateCSS selectorTailwind variantWhat triggers it
Default.btn(no prefix)Nothing
Hover.btn:hoverhover:A pointer over the button. Touch screens have no true hover.
Focus-visible.btn:focus-visiblefocus-visible:Focus the browser decides should be shown, typically keyboard focus (Tab)
Focus.btn:focusfocus:Any focus, including a mouse click in most browsers
Active.btn:activeactive:While the mouse button, finger or Space key is held down
Disabled.btn:disableddisabled:The disabled attribute
Disabled (ARIA).btn[aria-disabled="true"]aria-disabled:The aria-disabled="true" attribute
Loading.btn[aria-busy="true"]aria-busy:Your script, while work is in progress

Order matters in plain CSS when the selectors have equal specificity: put :hover first, then :focus-visible, then :active, so pressing wins over hovering. Tailwind sorts its variants for you.

The full button in plain CSS

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 2.5rem;              /* 40px, above the 24px WCAG minimum */
  padding: 0.625rem 1rem;
  border: 1px solid transparent;    /* keeps size equal to the outline variant */
  border-radius: 0.5rem;
  background: #2563eb;
  color: #fff;
  font: 600 0.875rem/1.25rem system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 150ms, transform 150ms;
}

/* hover only when the button can actually be used */
.btn:hover:not(:disabled, [aria-disabled="true"]) {
  background: #1d4ed8;
}

/* keyboard focus; mouse clicks do not show the ring */
.btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

.btn:active:not(:disabled, [aria-disabled="true"]) {
  background: #1e40af;
  transform: translateY(1px);
}

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn[aria-busy="true"] {
  cursor: progress;
}

@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn:active { transform: none; }
}

Three details do most of the work. The :not(:disabled, [aria-disabled="true"]) guard stops a disabled button from reacting to hover or press, which otherwise makes it look clickable. The transparent border keeps a solid button the same size as an outline one, so swapping variants does not shift the layout. And :focus-visible instead of :focus means mouse users do not see a ring after clicking, while keyboard users always do.

Use it like this: <button type="button">Save changes</button>. Always set type: a <button> inside a form defaults to type="submit" and will submit the form when clicked.

The same button in Tailwind

<button type="button"
 >
  Save changes
</button>

These classes work in Tailwind 3.4 and 4. Two v4 notes: hover: only applies on devices that support hover (@media (hover: hover)), and v4's base styles give buttons cursor: default, so add cursor-pointer if you want the hand cursor. See the Tailwind v4 cheat sheet for the other renamed defaults, such as the 1px ring.

Focus-visible: the state you cannot skip

WCAG 2.4.7 Focus Visible (level AA) requires that keyboard focus is visible. Removing the browser outline with outline: none and adding nothing back fails it. Guidelines that hold up:

The loading state

A loading button needs to do four things: show that work is happening, block a second submit, keep keyboard focus where it was, and tell screen reader users when the work finishes. Setting disabled during the request breaks the third point: the button loses focus and keyboard users are dropped back to the top of the page in some browsers. Use aria-disabled and guard in your script instead.

<button type="submit" aria-disabled="true" aria-busy="true">
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
    <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/>
    <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
  </svg>
  Saving
</button>
<!-- one live region on the page, outside the button -->
<p role="status">Saving your changes</p>
.spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .spinner { animation-duration: 2.4s; }  /* slow it, keep a sign of progress */
}
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (button.getAttribute("aria-disabled") === "true") return; // ignore double clicks
  button.setAttribute("aria-disabled", "true");
  button.setAttribute("aria-busy", "true");
  status.textContent = "Saving your changes";
  try {
    await save(new FormData(form));
    status.textContent = "Saved";
  } catch {
    status.textContent = "Could not save. Try again.";
  } finally {
    button.removeAttribute("aria-disabled");
    button.removeAttribute("aria-busy");
  }
});

Keep the label as a verb ("Saving", "Sending") instead of replacing it with only a spinner, keep the button's width fixed (a min-width stops it shrinking when the text changes) and announce the result through the status region, not by changing the button text alone. aria-busy is a hint that the element is being updated; screen readers vary in what they do with it, which is why the status message carries the actual announcement.

disabled vs aria-disabled: the real difference

Both make assistive technology announce the button as unavailable (often "dimmed" or "unavailable"). The difference is behavior. As MDN puts it, the HTML disabled attribute suppresses all functionality, while aria-disabled="true" only exposes the disabled state, so you must suppress the behavior yourself.

disabledaria-disabled="true"
Reachable with TabNo, it leaves the tab orderYes, it stays focusable
Click and keyboard activationBlocked by the browserStill fire; your script must ignore them
Submits a formNo. Its name and value are not sent, and if it is the form's default button, pressing Enter in a field does not submitYes, unless you call preventDefault()
Can show a tooltip or explanation on focusNo, it cannot receive focusYes, with aria-describedby
Styling hook:disabled / disabled:[aria-disabled="true"] / aria-disabled:
Works on links (<a>)No, disabled is not valid on linksYes, combined with removing href

Rule of thumb: use disabled when the button truly cannot be used and nobody needs to discover why, for example a form control inside a section that is switched off. Use aria-disabled when a user needs to find the button and learn what is missing ("Fill in your email to continue"), and during loading. Often the best answer is neither: leave the button enabled and show a clear error message when it is pressed with missing input.

button.addEventListener("click", (event) => {
  if (button.getAttribute("aria-disabled") === "true") {
    event.preventDefault();   // also stops a submit button from submitting
    return;
  }
  doTheThing();
});

On contrast: WCAG 1.4.3 exempts text in inactive (disabled) components from the minimum contrast rule, which is why a faded 50% opacity button is allowed. It is still worth keeping the label readable, because people need to see what they cannot do yet.

Outline vs ghost buttons

An outline button has a transparent fill and a visible border; a ghost button has neither until hovered. Use them for less important actions next to a solid primary button: outline for a clear second choice ("Preview"), ghost for quiet actions in toolbars and menus ("Cancel", "Edit").

These are real buttons: hover them and press Tab to see each state. CSS for the variants, layered on the .btn base above:

/* Outline: transparent fill, visible border */
.btn-outline {
  background: transparent;
  color: #1d4ed8;
  border-color: #2563eb;
}
.btn-outline:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-outline:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }

/* Ghost: no fill, no visible border until hover */
.btn-ghost {
  background: transparent;
  color: #1d4ed8;
  border-color: transparent;
}
.btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-ghost:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }

/* Destructive */
.btn-danger { background: #dc2626; }
.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: #b91c1c; }
.btn-danger:focus-visible { outline-color: #dc2626; }
<!-- Outline -->
<button type="button">
  Outline
</button>

<!-- Ghost -->
<button type="button">
  Ghost
</button>

<!-- Destructive -->
<button type="button">
  Delete project
</button>

WCAG 1.4.11 Non-text Contrast asks for 3:1 against adjacent colors for the parts of a control needed to identify it. The outline's blue-600 border on white clears that. A ghost button has no boundary at rest, so it relies on its label and placement to look clickable: keep ghost buttons where people expect actions, such as toolbars, dialogs and card footers, and keep their text color dark enough for normal text contrast (4.5:1).

Close (X) button

An X icon has no text, so it needs an accessible name. Put aria-label="Close" on the button and aria-hidden="true" on the SVG so screen readers announce "Close, button" instead of nothing or a file name. If a page has several close buttons, make the names specific: "Close cookie notice", "Close dialog".

Your changes were saved.

<button type="button" aria-label="Close">
  <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
    <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
  </svg>
</button>
.btn-close {
  display: inline-grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;          /* 40px target; WCAG 2.2 AA minimum is 24 by 24 */
  padding: 0;
  border: 0;
  border-radius: 9999px;
  background: transparent;
  color: #334155;
  cursor: pointer;
}
.btn-close:hover { background: rgb(15 23 42 / 0.08); }
.btn-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
<button type="button" aria-label="Close"
 >
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
    <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
  </svg>
</button>

Size the hit area, not just the icon. WCAG 2.5.8 Target Size (Minimum), level AA in WCAG 2.2, asks for targets of at least 24 by 24 CSS pixels (with exceptions, such as enough spacing around a smaller target). The 40px button above clears it with room to spare while the drawn X stays 20px. Avoid using the letter "x" or the "×" character as the only content without a label: screen readers read it as "x" or "times".

Icon + text and icon-only buttons

<!-- Icon + text: the text is the name, the icon is decoration -->
<button type="button">
  <svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24">...</svg>
  Download PDF
</button>

<!-- Icon only, option 1: aria-label -->
<button type="button" aria-label="Download PDF">
  <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
</button>

<!-- Icon only, option 2: visually hidden text (gets translated by browser translation tools) -->
<button type="button">
  <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
  <span>Download PDF</span>
</button>
.btn svg { flex: none; }          /* keep icons from squashing */
.btn-icon {
  width: 2.5rem;
  padding: 0;                       /* square: same width and height */
}
<button type="button">
  <svg aria-hidden="true" ...></svg>
  Download PDF
</button>

<button type="button" aria-label="Download PDF">
  <svg aria-hidden="true" ...></svg>
</button>

Checklist before you ship a button

More button styles

To design a button visually and copy its CSS, use the CSS button generator. For ready-made sets, browse the button components and animated buttons. Two pieces that pair well with this guide: the HTML and CSS button set with primary, secondary, outline, ghost, icon, loading and destructive styles, and the CSS button styles collection with copyable HTML and CSS. For where buttons sit on a page and what they should say, read call to action examples.

Frequently asked questions

How do I style a disabled button in CSS?

Target the :disabled pseudo-class, for example .btn:disabled { opacity: 0.5; cursor: not-allowed; }. If you also use aria-disabled, add [aria-disabled="true"] to the same rule. Guard your hover and active styles with :not(:disabled) so a disabled button does not change color when hovered or pressed.

What is the difference between disabled and aria-disabled?

disabled removes the button from the tab order, blocks clicks and stops it from submitting a form. aria-disabled="true" only tells assistive technology the button is unavailable: it stays focusable and clickable, so your script must ignore activation. Use aria-disabled when users need to focus the button to learn why it is unavailable, or while a request is loading.

Should I use :focus or :focus-visible for buttons?

Use :focus-visible for the visible ring. The browser shows it for keyboard focus but usually not after a mouse click, so keyboard users always see where they are without a ring appearing on every click. Never remove the outline without adding a visible replacement, since WCAG 2.4.7 requires a visible focus indicator.

How do I make an accessible close (X) button?

Use a real button element with type="button" and aria-label="Close" (or a more specific name such as "Close dialog"), and put aria-hidden="true" on the X icon. Make the clickable area at least 24 by 24 CSS pixels, the WCAG 2.2 AA minimum; 40 to 44 pixels is easier to hit.

What is the difference between an outline button and a ghost button?

An outline button has a transparent background and a visible border, so it reads as a button at rest. A ghost button has no background and no visible border until it is hovered, so it relies on its text and position. Use outline for a clear secondary action and ghost for quiet actions in toolbars or menus.

How do I show a loading state on a button?

Add a spinner with aria-hidden="true", change the label to a verb such as Saving, set aria-disabled="true" so a second click is ignored while focus stays on the button, and announce the result through a role="status" region. Remove the attributes when the request finishes or fails.

More in Guides

Alternatives mentioned in this article

Related guides and tools

Start building

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap