Buttons
Accessible button variants, sizes, states and groups built from Tailwind utilities — plus a reusable component API.
Buttons are the most-copied component in any codebase, so it is worth getting the base right once.
Anatomy#
Every solid button needs six things:
- Shape —
rounded-md px-4 py-2 - Type —
text-sm font-medium - Colour — background and text with sufficient contrast
- Hover — a visible change
- Focus —
focus-visible:outline-2 focus-visible:outline-offset-2 - Disabled —
disabled:opacity-50 disabled:cursor-not-allowed
Variants#
Loading preview…
HTML
<div class="flex flex-wrap items-center gap-3 p-6">
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 active:bg-indigo-700">
Primary
</button>
<button class="rounded-md bg-white px-4 py-2 text-sm font-medium text-gray-900 shadow-sm ring-1 ring-gray-300 transition ring-inset hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Secondary
</button>
<button class="rounded-md px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Ghost
</button>
<button class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600">
Destructive
</button>
<button class="rounded-md px-4 py-2 text-sm font-medium text-indigo-600 underline-offset-4 transition hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Link
</button>
</div>| Variant | Use for |
|---|---|
| Primary | The single most important action on the screen |
| Secondary | Alternative actions of equal weight |
| Ghost | Low-emphasis actions in dense UI |
| Destructive | Delete, revoke, cancel subscription |
| Link | Navigation styled as text |
Sizes#
Loading preview…
HTML
<div class="flex flex-wrap items-center gap-3 p-6">
<button class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500">Extra small</button>
<button class="rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Small</button>
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Medium</button>
<button class="rounded-md bg-indigo-600 px-5 py-2.5 text-base font-medium text-white hover:bg-indigo-500">Large</button>
</div>States#
Loading preview…
HTML
<div class="flex flex-wrap items-center gap-3 p-6">
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">Default</button>
<button disabled class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50">Disabled</button>
<button class="inline-flex items-center gap-2 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white" aria-busy="true">
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" class="opacity-25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
Saving…
</button>
</div>With icons#
Loading preview…
HTML
<div class="flex flex-wrap items-center gap-3 p-6">
<button class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" d="M12 5v14m-7-7h14"/></svg>
New project
</button>
<button class="inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-gray-300 ring-inset hover:bg-gray-50">
Continue
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14m-6-6 6 6-6 6"/></svg>
</button>
<button class="rounded-md p-2 text-gray-600 hover:bg-gray-100" >
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
<span class="sr-only">Close</span>
</button>
</div>Two rules for icon buttons:
- Decorative icons get
aria-hidden="true". - Icon-only buttons need an accessible name —
sr-onlytext oraria-label.
Button groups#
Loading preview…
HTML
<div class="p-6">
<div class="inline-flex isolate rounded-md shadow-sm" role="group" aria-label="View">
<button class="rounded-l-md bg-white px-3 py-2 text-sm font-medium text-gray-900 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 focus:z-10">Day</button>
<button class="-ml-px bg-white px-3 py-2 text-sm font-medium text-gray-900 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 focus:z-10">Week</button>
<button class="-ml-px rounded-r-md bg-white px-3 py-2 text-sm font-medium text-gray-900 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 focus:z-10">Month</button>
</div>
</div>-ml-px collapses the double border between buttons; focus:z-10 brings the focused ring above its neighbours; isolate keeps that z-index contained.
A reusable component#
Button.jsx
const BASE =
'inline-flex items-center justify-center gap-2 rounded-md font-medium ' +
'transition focus-visible:outline-2 focus-visible:outline-offset-2 ' +
'disabled:pointer-events-none disabled:opacity-50'
const VARIANTS = {
primary: 'bg-indigo-600 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline-indigo-600',
secondary: 'bg-white text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset hover:bg-gray-50 focus-visible:outline-indigo-600',
ghost: 'text-gray-700 hover:bg-gray-100 focus-visible:outline-indigo-600',
danger: 'bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:outline-red-600',
}
const SIZES = {
sm: 'px-2.5 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
}
export function Button({ variant = 'primary', size = 'md', className = '', ...props }) {
return (
<button
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
{...props}
/>
)
}Note the lookup objects hold complete class names, so Tailwind's source detection finds them. Never build them by interpolation.
Links that look like buttons#
HTML
<a href="/signup" class="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
Get started
</a>Common mistakes#
- Removing focus styles.
focus:outline-nonewith no replacement makes the UI keyboard-unusable. <div>as a button. No keyboard support, no role, no focus.- Missing
type="button"inside a form. The default issubmit, so a "Cancel" button submits the form. - Icon-only buttons with no label. Screen readers announce nothing.
- Insufficient contrast on disabled buttons.
opacity-50can push text below 4.5:1 — that is generally accepted for disabled controls, but do not use the same treatment for anything the user must read. - Interpolating variant class names. `
bg-${color}-600` produces no CSS.
Related topics#
- Forms — inputs, validation and submission
- States & Variants — hover, focus, disabled
- Transitions — feedback timing
- Colors — contrast requirements