Button

A clickable control that triggers an action.

Basic

Add .button to a <button>, then pair it with a tone modifier for the role. .button--primary is the main call to action; use one per surface for the action you most want the user to take.

Neutral

A quieter button next to primary. Use for actions that should sit alongside primary without competing for attention, like Cancel next to Save.

The canonical pairing in context.

Tertiary

An alternative to primary when the brand color is already taken on the page. The fill flips by theme, dark in light mode and light in dark mode.

Danger

For destructive actions like Delete or Discard. Filled red is the safe default for a confirm dialog; outline and soft fit softer surfaces such as row-level deletes.

Ghost

No background, no border, just a colored label. Use for low-stakes actions in toolbars or quiet inline triggers. Hover paints a soft tint so the affordance shows on interaction.

Custom color

For one-off colors outside the shipped tones, set --button-tone and --button-color inline. The fill gradient, rim, and inset edge all derive from --button-tone, and hover/active fade a white --button-overlay wash over it, so a one-line override carries every state. If the same color appears in three or more places, promote it to a project class.

Sizes

Heights are pinned across each size regardless of content. Base (unmodified) is md; .button--sm, .button--lg, and .button--xl step it.

With icon

Icons drop in via flexbox gap and scale to the label's font size.

Icon only

Add .button--icon-only for a square button at any size. Pair with aria-label so the action stays announced. Add .button--pill to round the ends fully; on an icon-only button that reads as a circle.

States

aria-pressed="true" applies the pressed-in fill on any tone. Native :disabled applies on form controls; aria-disabled="true" mirrors it on link buttons.

Loading

Set aria-busy="true" to show a leading spinner. The label stays; leading or trailing icons hide so the spinner takes their place. Click is blocked while busy. The spinner is pure CSS, no JS in this demo.

With icons, the spinner replaces them in place.

Works on any element

.button renders the same on <button>, <a>, and form inputs.

Customization

Override on .button itself, on a parent scope, or on :root. The cascade scopes the change. The variable surface splits into four groups.

Geometry and sizes

Shape, height, and text scale. Size modifiers (--sm, --lg, --xl) reassign each row to the corresponding value.

VariableUse
--button-radiusCorner radius. Size modifiers step it to a smaller or larger tier
--button-heightHard single-line height — a --spacing() multiple, so it tracks --spacing
--button-padding-inlineInline padding (left and right)
--button-padding-blockBlock padding (top and bottom); defaults to 0 since the height owns the rhythm. --wrap sets it for multi-line buttons
--button-border-widthBorder thickness; set 0 to drop the rim
--button-font-sizeLabel size
--button-font-weightLabel weight
--button-icon-sizeWidth and height of inline icons

Color knobs

The surface variables that paint each state. Most default from --button-tone, so retoning is usually a one-line --button-tone override.

VariableUse
--button-bgBackground fill. Defaults to a lighter-tone → tone gradient; set a flat color to override. Stays fixed across states — --button-overlay paints hover/active
--button-colorLabel color. Pairs with --button-tone for legibility across overrides
--button-tone-emphasisLabel color for --outline/--ghost/--soft, where the tone is text on a transparent or tinted bg. Intents set it from --color-<intent>-emphasis so it clears AA contrast; falls back to --button-tone
--button-overlayColor of the hover/active wash faded over the fill. White by default; neutral washes with foreground and tertiary with background so the wash stays visible on any fill
--button-overlay-strengthPeak (active) opacity of the wash; hover shows 0.6 of it. Raise for a stronger wash, set 0 to disable (outline/ghost/soft do this)
--button-border-colorRim border — recessed in light, rim-lit in dark
--button-inset-shadow-colorColor of the all-around inset edge. Set transparent to drop it (outline/ghost/soft do this automatically)

Tone source

One variable carries the button's color; the four color knobs derive from it. Tone modifiers reassign --button-tone to the matching token.

ModifierSets --button-tone
.button--primaryvar(--color-primary) + --button-color: var(--color-primary-foreground)
.button--dangervar(--color-danger) + --button-color: var(--color-danger-foreground)
.button--neutralvar(--color-neutral) + --button-tone-emphasis: var(--color-foreground), --button-color: var(--color-neutral-foreground)
.button--tertiaryvar(--color-foreground) + --button-color: var(--color-background); flips with theme

Shape variants

Outline, ghost, and soft override the color knobs while leaving --button-tone intact, so a single tone reads three ways. Each drops the inset edge and wash (--button-inset-shadow-color: transparent and --button-overlay-strength: 0).

Modifier--button-bg--button-border-color
.button--outlinetransparentvar(--button-tone)
.button--ghosttransparenttransparent
.button--softtone @ 12% over transparenttransparent