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.
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.
| State | CSS selector | Tailwind variant | What triggers it |
|---|---|---|---|
| Default | .btn | (no prefix) | Nothing |
| Hover | .btn:hover | hover: | A pointer over the button. Touch screens have no true hover. |
| Focus-visible | .btn:focus-visible | focus-visible: | Focus the browser decides should be shown, typically keyboard focus (Tab) |
| Focus | .btn:focus | focus: | Any focus, including a mouse click in most browsers |
| Active | .btn:active | active: | While the mouse button, finger or Space key is held down |
| Disabled | .btn:disabled | disabled: | 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.
.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.
<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.
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:
:focus-visible for the ring. It is supported in all current major browsers, so the old :focus:not(:focus-visible) workaround is no longer needed.outline over box-shadow for the ring. Outlines stay visible in Windows forced colors (high contrast) mode, where box shadows are removed.outline-offset of 2px leaves a gap between the button and the ring, so a blue ring still reads against a blue button.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.
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.
disabled | aria-disabled="true" | |
|---|---|---|
| Reachable with Tab | No, it leaves the tab order | Yes, it stays focusable |
| Click and keyboard activation | Blocked by the browser | Still fire; your script must ignore them |
| Submits a form | No. Its name and value are not sent, and if it is the form's default button, pressing Enter in a field does not submit | Yes, unless you call preventDefault() |
| Can show a tooltip or explanation on focus | No, it cannot receive focus | Yes, with aria-describedby |
| Styling hook | :disabled / disabled: | [aria-disabled="true"] / aria-disabled: |
Works on links (<a>) | No, disabled is not valid on links | Yes, 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.
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).
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: 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>
aria-hidden="true"), let the text be the name. Use gap instead of a margin on the icon so spacing still works if the icon is removed or moved to the right.aria-label or visually hidden text. Do not rely on title: browsers use it as a fallback name, but its tooltip appears on mouse hover only, not on keyboard focus or touch.focusable="false" to inline SVGs if you still support old versions of Internet Explorer, which made SVGs tab stops. Modern browsers do not need it, but it is harmless.<button> (for actions) or an <a href> (for navigation), not a clickable <div>.type="button" unless it should submit a form.prefers-reduced-motion.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.
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.
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.
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.
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.
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.
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.
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.