Button

A clickable control that triggers an action.

Primary

The main call to action. Use one per surface for the action you most want the user to take.

<button type="button" class="btn btn--primary">Primary</button>

Neutral

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

<div class="demo-row">
  <button type="button" class="btn btn--neutral">Neutral</button>
  <button type="button" class="btn btn--outline btn--neutral">Outline</button>
</div>

The canonical pairing in context.

<div class="demo-row">
  <button type="button" class="btn btn--primary">Save changes</button>
  <button type="button" class="btn btn--outline btn--neutral">Cancel</button>
</div>

Tertiary

An alternative to primary when the brand color is already taken on the page and a second prominent action needs to stand apart. The fill flips by theme, dark in light mode and light in dark mode.

<button type="button" class="btn btn--tertiary">Tertiary</button>

Danger

For destructive actions like Delete, Remove, or Discard. The filled red is the safe default for a confirm dialog. Outline and light fit softer surfaces such as row-level deletes inside a dense table.

<div class="demo-row">
  <button type="button" class="btn btn--danger">Danger</button>
  <button type="button" class="btn btn--outline btn--danger">Outline</button>
  <button type="button" class="btn btn--soft btn--danger">Soft</button>
</div>

Ghost

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

<div class="demo-row">
  <button type="button" class="btn btn--ghost btn--neutral">Cancel</button>
  <button type="button" class="btn btn--ghost btn--primary">View details</button>
  <button type="button" class="btn btn--ghost btn--danger">Remove</button>
</div>

Custom color

For one-off colors outside the shipped tones, set --btn-tone and --btn-color inline. The bg, hover, active, rim, and bevel all derive from --btn-tone, so a one-line override carries every state.

If the same color appears in three or more places, promote it to a project-scoped class. The shipped tones (primary, neutral, tertiary, danger) are the curated set with verified text contrast across light and dark.

<div class="demo-row">
  <button type="button" class="btn" style="--btn-tone: oklch(0.55 0.18 149); --btn-color: white;">
    Custom green
  </button>
  <button type="button" class="btn" style="--btn-tone: oklch(0.55 0.15 285); --btn-color: white;">
    Custom violet
  </button>
  <button type="button" class="btn" style="--btn-tone: oklch(0.65 0.18 55); --btn-color: white;">
    Custom orange
  </button>
</div>

Sizes

Heights are pinned across small, default, and large regardless of content.

<div class="demo-row">
  <button type="button" class="btn btn--primary btn--sm">Small</button>
  <button type="button" class="btn btn--primary">Default</button>
  <button type="button" class="btn btn--primary btn--lg">Large</button>
</div>

With icon

Icons drop into .btn via flexbox gap. Any inline <svg> or <i> scales to the button's font size.

<div class="demo-row">
  <button type="button" class="btn btn--primary">
    <i data-lucide="plus"></i>
    Leading icon
  </button>
  <button type="button" class="btn btn--primary">
    Trailing icon
    <i data-lucide="arrow-right"></i>
  </button>
</div>

Icon only

Add .btn--icon-only for a square button at any size. Pair with aria-label so the action stays announced. Add .btn--icon-round for a circular silhouette.

<div class="demo-row">
  <button type="button" class="btn btn--primary btn--icon-only btn--sm" aria-label="Add">
    <i data-lucide="plus"></i>
  </button>
  <button type="button" class="btn btn--primary btn--icon-only" aria-label="Add">
    <i data-lucide="plus"></i>
  </button>
  <button type="button" class="btn btn--primary btn--icon-only btn--lg" aria-label="Add">
    <i data-lucide="plus"></i>
  </button>
  <button type="button" class="btn btn--outline btn--danger btn--icon-only" aria-label="Delete">
    <i data-lucide="trash-2"></i>
  </button>
  <button type="button" class="btn btn--outline btn--neutral btn--icon-only" aria-label="Edit">
    <i data-lucide="pencil"></i>
  </button>
  <button type="button" class="btn btn--ghost btn--neutral btn--icon-only btn--icon-round" aria-label="More">
    <i data-lucide="more-horizontal"></i>
  </button>
</div>

States

aria-pressed="true" applies the pressed-in fill on any tone. Native :disabled applies on form controls; aria-disabled="true" and tabindex="-1" mirror the same behavior on link buttons.

Disabled link
<div class="demo-row">
  <button type="button" class="btn btn--primary" aria-pressed="true">Pressed</button>
  <button type="button" class="btn btn--primary" disabled>Disabled button</button>
  <a href="#" role="button" class="btn btn--primary" aria-disabled="true" tabindex="-1">Disabled link</a>
</div>

Loading

Toggle .is-loading from JS to show a leading spinner. The label stays visible. Any leading or trailing icons hide so the spinner takes their place. Click is blocked while the class is applied. Pair with aria-busy="true".

<div class="demo-row">
  <button type="button" class="btn btn--primary is-loading btn--sm" aria-busy="true">Saving</button>
  <button type="button" class="btn btn--primary is-loading" aria-busy="true">Saving</button>
  <button type="button" class="btn btn--primary is-loading btn--lg" aria-busy="true">Saving</button>
  <button type="button" class="btn btn--outline btn--neutral is-loading" aria-busy="true">Loading</button>
  <button type="button" class="btn btn--danger btn--icon-only is-loading" aria-busy="true" aria-label="Deleting"></button>
</div>

With icons, the spinner replaces them in place.

<div class="demo-row">
  <button type="button" class="btn btn--primary is-loading" aria-busy="true">
    <i data-lucide="plus"></i>
    Adding
  </button>
  <button type="button" class="btn btn--primary is-loading" aria-busy="true">
    Continuing
    <i data-lucide="arrow-right"></i>
  </button>
</div>

Click to try. The state auto-clears after two seconds.

<button type="button" class="btn btn--primary" data-demo-loading="2000">
  <i data-lucide="save"></i>
  Save changes
</button>

Works on any element

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

<a>
<div class="demo-row">
  <button type="button" class="btn btn--primary">&lt;button&gt;</button>
  <a href="#" role="button" class="btn btn--primary">&lt;a&gt;</a>
  <input type="submit" class="btn btn--primary" value="<input type=submit>" />
</div>

Customization

Override on .btn 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) reassign each row to the corresponding small or large value.

Variable Default Use
--btn-radius var(--st-radius) Corner radius (per-component override via --st-btn-radius; sizes pull --st-radius-sm / -lg)
--btn-height 2.25rem (default), 1.75rem (--sm), 2.75rem (--lg) Hard single-line height. Multiplied by --st-density at the component level
--btn-padding-x calc(0.75rem * var(--st-density)) (default), 0.625rem (--sm), 1rem (--lg) Horizontal padding
--btn-font-size 0.875rem (default), 0.8125rem (--sm), 1rem (--lg) Label size
--btn-font-weight 500 Label weight

.btn--icon-only zeroes --btn-padding-x and pins width to --btn-height for a 1:1 square.

Color knobs

The four surface variables that paint each state. Each one defaults from --btn-tone (covered in the next group), so retoning the button is usually a one-line --btn-tone override rather than four separate writes.

Variable Default Use
--btn-bg var(--btn-tone) Background. :hover mixes 88% over black; :active 78%; :disabled 50% over transparent
--btn-color (tone-modifier sets it) Label color. Pairs with --btn-tone so a white-on-tone button stays legible across token overrides
--btn-border-color color-mix(in oklch, var(--btn-tone) 85%, black) Rim border. Reads as recessed in light and rim-lit in dark
--btn-bevel inset 0 1px 0 rgba(255, 255, 255, 0.15) Inset top highlight. Set to none for a flat-button look (outline, ghost, and soft variants do this automatically)

Tone source

One variable carries the button's brand color. The four color knobs above derive from it, so a single --btn-tone override retones every state. Tone modifiers reassign --btn-tone to the matching token.

Variable Default per modifier Use
--btn-tone set by tone modifier Source color for --btn-bg and --btn-border-color derivations
.btn--primary var(--st-primary) Pairs with --btn-color: var(--st-primary-foreground)
.btn--danger var(--st-danger) Pairs with --btn-color: var(--st-danger-foreground)
.btn--neutral var(--st-foreground) Asymmetric: --btn-bg is forced to --st-neutral and --btn-border-color mixes neutral with muted foreground so the rim reads as recessed
.btn--tertiary var(--st-foreground) Pairs with --btn-color: var(--st-background). Fill flips with the theme

For one-off colors, set --btn-tone and --btn-color inline (see Custom color above). The bg, hover, active, rim, and bevel all follow from that one assignment.

Shape variants

The outline, ghost, and soft modifiers override the color knobs while leaving --btn-tone intact, so a single tone reads three different ways. Each modifier sets --btn-bevel: none because the inset highlight only reads on filled surfaces.

Modifier --btn-bg --btn-color --btn-border-color
.btn--outline transparent var(--btn-tone) var(--btn-tone)
.btn--ghost transparent var(--btn-tone) transparent
.btn--soft color-mix(in oklch, var(--btn-tone) 12%, transparent) var(--btn-tone) transparent

Hover on outline and ghost paints --st-accent; hover on soft increases the bg mix from 12% to ~20%. Active on each shape darkens accordingly.