Overview
The Button is the most fundamental interactive element in any user interface. It communicates that an action will occur when the user clicks or taps it — submitting a form, opening a dialog, navigating to a new page, or triggering a process.
Despite its simplicity, the button is where most design systems either shine or fall apart. A well-designed button system handles hierarchy (primary vs. secondary vs. tertiary), communicates state clearly (loading, disabled), and remains accessible to every user regardless of input method.
When to use a Button:
- To trigger an action (submit, save, delete, confirm)
- To initiate a process (upload, export, send)
- To toggle a state when the result isn't instant (use a Switch for instant toggles)
When NOT to use a Button:
- For navigation — use an anchor (
<a>) styled as a link or a Navigation Bar - For selecting options — use a Select, Checkbox, or Radio Button
- For toggling between two immediate states — use a Switch
Use our Button Generator to experiment with padding, border-radius, shadow, and color values in real time. Check your text-to-background contrast with the Contrast Checker before shipping.
Variants
Common Button Variants
| Variant | Purpose | Visual Treatment |
|---|---|---|
| Primary | The main call-to-action on a page. One per section maximum. | Solid fill with brand color, white text. High visual weight. |
| Secondary | Supporting actions that complement the primary. | Outlined or muted fill. Lower visual weight than primary. |
| Ghost / Tertiary | Low-emphasis actions like "Cancel" or "Skip". | No fill, no border. Text-only with hover background. |
| Destructive | Irreversible actions (delete, remove, revoke). | Red fill or red outline. Signals danger. |
| Link Button | Actions that look like inline text links but behave as buttons. | Underlined text, no background. |
| Icon-only | Compact actions in toolbars or tight layouts. | Square aspect ratio, icon centered. Always needs aria-label. See Icon Button. |
Size Variants
| Size | Height | Padding (horizontal) | Font Size | Use Case |
|---|---|---|---|---|
| Small (sm) | 32px | 12px | 13px | Inline actions, table rows, dense UIs |
| Medium (md) | 40px | 16px | 14px | Default for most interfaces |
| Large (lg) | 48px | 24px | 16px | Hero sections, mobile-first layouts |
Shape Variants
Experiment with border-radius values using our Border Radius Generator:
- Rounded (4–8px): Professional, enterprise feel
- Pill (9999px): Friendly, modern, consumer-facing
- Square (0px): Brutalist or highly structured layouts
Properties
Button Properties
| Property | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'destructive' | 'link' | 'primary' | Visual style of the button |
size | 'sm' | 'md' | 'lg' | 'md' | Controls height, padding, and font size |
disabled | boolean | false | Prevents interaction and applies disabled styling |
loading | boolean | false | Shows a spinner and disables interaction |
fullWidth | boolean | false | Stretches button to fill container width |
type | 'button' | 'submit' | 'reset' | 'button' | HTML button type. Always set explicitly. |
leftIcon | ReactNode | — | Icon rendered before the label |
rightIcon | ReactNode | — | Icon rendered after the label |
as | ElementType | 'button' | Polymorphic element override (e.g., render as <a>) |
Important: Always set type="button" explicitly. Browsers default to type="submit" inside forms, which causes accidental form submissions — one of the most common button bugs in production.
Token Mappings
Design Token Mappings
Buttons touch almost every token category. Here's how they map. For a deeper dive on tokens, read our Design Tokens Complete Guide.
| Token Category | Token Example | Button Usage |
|---|---|---|
| Color – Fill | --color-primary-600 | Primary button background |
| Color – Text | --color-on-primary | Primary button label color |
| Color – Border | --color-primary-700 | Secondary button border |
| Color – Destructive | --color-error-600 | Destructive button background |
| Spacing – Padding | --space-3 / --space-4 | Horizontal padding per size |
| Spacing – Gap | --space-2 | Gap between icon and label |
| Border Radius | --radius-md (8px) | Corner rounding. Preview with Border Radius Generator. |
| Typography | --font-weight-semibold, --font-size-sm | Label text styling |
| Shadow | --shadow-xs | Subtle elevation on primary buttons |
| Transition | --duration-fast (150ms) | Hover/active state transitions |
Use our Color Palette Generator to create cohesive button color sets, and the Shadow Generator for elevation values.
States
Interactive States
| State | Visual Change | Behavior |
|---|---|---|
| Default | Base styling per variant | Ready for interaction |
| Hover | Slight background darken (8–12%) or lighten. Cursor changes to pointer. | Indicates interactivity. Transition: 150ms ease. |
| Focus | Visible focus ring (2px offset, high-contrast color). Never remove this. | Keyboard navigation indicator. Must meet WCAG 2.1 SC 2.4.7. |
| Active / Pressed | Scale down slightly (transform: scale(0.98)) or further darken. | Confirms the click registered. |
| Disabled | Reduced opacity (0.4–0.5). Cursor changes to not-allowed. | aria-disabled="true" preferred over disabled attribute for screen reader announcements. |
| Loading | Spinner replaces or sits beside label. Button is non-interactive. | Prevents double-submission. Keep the button width stable — don't let it collapse when the label disappears. |
Focus Ring Best Practices
Use a 2px solid ring with a 2px offset from the button edge. The ring color should have at least 3:1 contrast against the surrounding background (WCAG 2.1 SC 1.4.11). Test with our Contrast Checker.
button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
Accessibility
WCAG Requirements
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.4.3 Contrast (Minimum) | AA | Button text must have 4.5:1 contrast against the button background. Use our Contrast Checker. |
| SC 1.4.11 Non-text Contrast | AA | Button boundary (border or fill) must have 3:1 contrast against the page background. |
| SC 2.4.7 Focus Visible | AA | Focus indicator must be visible when navigating by keyboard. |
| SC 2.5.8 Target Size (Minimum) | AA | Minimum 24×24px target size (WCAG 2.2). Aim for 44×44px on touch devices. |
| SC 4.1.2 Name, Role, Value | A | Buttons must have an accessible name. Icon-only buttons require aria-label. |
ARIA Attributes
- Use native
<button>— it has thebuttonrole built in. Avoid<div role="button">unless absolutely necessary (you then need to handle Enter, Space, and focus yourself). - Icon-only buttons: Add
aria-label="Delete item"or wrap with visually-hidden text. - Toggle buttons: Use
aria-pressed="true|false". - Loading state: Add
aria-busy="true"and optionallyaria-label="Saving, please wait". - Disabled: Prefer
aria-disabled="true"over thedisabledattribute so screen readers can still discover and announce the button.
Keyboard Interaction
| Key | Action |
|---|---|
| Enter | Activates the button |
| Space | Activates the button (native <button> handles both) |
| Tab | Moves focus to the next focusable element |
| Shift + Tab | Moves focus to the previous focusable element |
For deeper accessibility patterns, see our WCAG Practical Guide and Keyboard Accessibility Guide.
Usage Guidelines
Do's
- ✅ Use a clear, verb-first label. "Save changes" not "Changes" or "OK". The label should describe the action.
- ✅ Limit to 2–3 button variants per page. One primary, one secondary, maybe a ghost. More than that creates visual noise.
- ✅ Place the primary action on the right in button pairs (e.g., "Cancel" left, "Submit" right) — this follows the natural reading flow in LTR languages.
- ✅ Show a loading state during async operations. Never let users wonder if their click registered.
- ✅ Keep button widths consistent within a group. Use
min-widthor full-width in mobile layouts.
Don'ts
- ❌ Don't use buttons for navigation. Use
<a>tags. If it goes to a URL, it's a link. - ❌ Don't disable buttons without explanation. If a form isn't valid, show validation errors instead. Disabled buttons with no context frustrate users.
- ❌ Don't use "Click here" as a label. It's meaningless to screen readers and tells users nothing about the action.
- ❌ Don't put two primary buttons side by side. If everything is important, nothing is.
- ❌ Don't rely on color alone to distinguish destructive buttons. Add an icon (trash, warning) or use clear labeling like "Delete permanently".
Content Guidelines
- Labels should be 2–4 words. If you need more, reconsider your UX.
- Use sentence case ("Save changes") not title case ("Save Changes") — it reads more naturally.
- For destructive actions, be specific: "Delete project" not just "Delete".
Learn more about button styling in our CSS Buttons guide and experiment live with our Button Generator.
Code Snippets
<!-- Primary Button -->
<button type="button" class="btn btn-primary">
Save changes
</button>
<!-- Secondary Button -->
<button type="button" class="btn btn-secondary">
Cancel
</button>
<!-- Destructive Button -->
<button type="button" class="btn btn-destructive">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Z"/>
</svg>
Delete project
</button>
<!-- Icon-only Button -->
<button type="button" class="btn btn-ghost btn-icon" aria-label="Close dialog">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"/>
</svg>
</button>
<!-- Loading Button -->
<button type="button" class="btn btn-primary" aria-busy="true" disabled>
<span class="spinner" aria-hidden="true"></span>
Saving…
</button>import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
type Variant = "primary" | "secondary" | "ghost" | "destructive";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
leftIcon,
rightIcon,
disabled,
children,
className = "",
type = "button",
...props
},
ref,
) => {
return (
<button
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
aria-disabled={disabled || undefined}
className={`btn btn-${variant} btn-${size} ${className}`}
{...props}
>
{loading && <span className="spinner" aria-hidden="true" />}
{!loading && leftIcon}
{children}
{!loading && rightIcon}
</button>
);
},
);
Button.displayName = "Button";
export default Button;
// Usage
<Button variant="primary" size="md" onClick={handleSave}>
Save changes
</Button>
<Button variant="destructive" loading={isDeleting}>
Delete project
</Button>Design Systems
Cross-System Comparison
| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Variants | Filled, Outlined, Text, Elevated, Tonal | default, destructive, outline, secondary, ghost, link | Unstyled primitive — BYO styles | primary, default, dashed, text, link |
| Sizes | Not explicit (density via tokens) | default, sm, lg, icon | N/A (primitive) | large, middle, small |
| Loading | CircularProgress overlay | No built-in loading | N/A | Built-in loading prop |
| Icon support | Icon prop + FAB variants | Manual via children | N/A | icon prop |
| Ripple effect | Yes (Material signature) | No | No | No |
| Polymorphic | No | asChild via Radix Slot | asChild pattern | No |
| Accessibility | Built-in focus management | Inherits Radix patterns | Fully accessible primitives | Basic ARIA support |
Notable Approaches
Material 3 introduced "Tonal" buttons — a middle ground between filled and outlined that uses a lighter shade of the primary color. This is excellent for secondary actions that need more emphasis than an outline but less than a filled button. Consider adopting this in your design system if you find the primary/secondary/ghost trio isn't granular enough.
Shadcn/ui leverages the Radix Slot component via asChild, allowing you to render a button's styles on any element — perfect for when you need a button that's actually a <Link> under the hood.
Radix Primitives doesn't provide a button primitive because native <button> already has the right semantics. Radix focuses on complex widgets (Dialog, Popover, etc.) where native elements fall short.
Ant Design bundles a loading prop directly into the button component, including an automatic spinner — a pragmatic approach that reduces boilerplate in enterprise apps.
For more on building component libraries, read our Building Component Libraries guide.