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.
<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.
<div class="demo-row">
<button type="button" class="btn btn--primary"><button></button>
<a href="#" role="button" class="btn btn--primary"><a></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.