Button
Triggers an action when activated — or navigates, as ButtonLink.
Buttons communicate actions that a user can take. Use them to trigger events, submit forms, or confirm decisions. When the control navigates to a URL instead, use ButtonLink — documented on this page, since it shares every variant, size, and token with Button. Color follows Color; spacing and radius follow the project conventions.
Variants
Three types, and emphasis is carried by weight rather than by hue: primary is a fill, outlined is a border, ghost is neither. That is the whole ladder, and it survives a theme change — a set that splits the middle rung into three near-identical greys gives you three buttons no one can choose between, and one palette swap can collapse them into each other.
---
import Button from '@/components/ui/Button.astro';
---
<Button variant="primary">Primary</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>| Variant | Weight | Use when | Typical labels |
|---|---|---|---|
primary | Filled | The single most important action in a view | Save changes, Continue, Publish |
outlined | Bordered | Supporting actions that still need a visible affordance at rest | Cancel, Back, Preview, Edit |
ghost | Neither | Inline, toolbar and navigation actions where a border would only add noise | Learn more, Dismiss, View details |
danger | Filled | Destructive or irreversible actions, with real consequence or data loss | Delete, Remove, Reset |
primary should be the most visually dominant action present, and there should be one per local action group.
danger is primary in the danger color, and only that. There is no outlined or ghost destructive button, deliberately: a control that deletes something should not whisper the consequence. If an action does not deserve a filled button, it does not deserve danger either — pair a ghost cancel with the danger confirm and let the weight difference do the work.
ghost steps its ink down to text-dark/70 where outlined holds full text-dark. With no border and no fill, that ink step is the only thing left to carry the bottom rung of the ladder, and /70 is 7.82:1 — the drop costs emphasis, not legibility.
Anatomy
- Root — the
<button>element; carries all interaction semantics - Label — the visible text content; always required unless icon-only
- Icon (optional) — leading or trailing icon placed inside the root
Structure
The button is defined by five independent dimensions: emphasis (variant), size, structure (what it contains), state, and behavior.
| Structure | Use when |
|---|---|
| Text only | The standard form — use unless there is a reason not to |
| Leading icon + text | The icon speeds up scanning or reinforces the action’s meaning |
| Trailing icon + text | The icon signals direction or a follow-up (external link, expand, arrow) |
| Icon only | The action is common and instantly recognizable — iconOnly, and always with aria-label |
| Split button | One main action has a few closely related alternatives |
- Icon-only buttons need
aria-label— a tooltip is an addition to it, never a substitute — and equal height and width, which is whaticonOnlysets. Do not use them for uncommon or ambiguous actions. - Split buttons stay out of the base kit unless the product genuinely needs them — they suit enterprise or power-user contexts.
With icons
An icon goes in the slot alongside the label — before it to reinforce what the action is, after it to signal where it goes or what follows. Nothing else changes: no icon prop, no size, no margin.
---
import { Icon } from 'astro-icon/components';
import Button from '@/components/ui/Button.astro';
import ButtonLink from '@/components/ui/ButtonLink.astro';
---
<Button variant="primary">
<Icon name="lucide:plus" aria-hidden="true" />
Add item
</Button>
<Button variant="outlined">
Continue
<Icon name="lucide:arrow-right" aria-hidden="true" />
</Button>
<Button variant="ghost">
<Icon name="lucide:sliders-horizontal" aria-hidden="true" />
Filters
<Icon name="lucide:chevron-down" aria-hidden="true" />
</Button>
<Button variant="danger">
<Icon name="lucide:trash-2" aria-hidden="true" />
Delete project
</Button>
<ButtonLink href="https://example.com" variant="ghost" target="_blank" rel="noopener noreferrer">
Documentation
<Icon name="lucide:external-link" aria-hidden="true" />
</ButtonLink>Call sites pass no size. [&_svg]:size-[1.15em] in the base sizes every icon from the button’s own label, so the same markup is correct at sm, md and lg, and it stays correct after the type scale is retuned. Passing size={16} or a size-4 class overrides that and breaks the ramp — astro-icon’s size prop is for icons outside a button.
<Button size="sm"><Icon name="lucide:plus" aria-hidden="true" />Add item</Button>
<Button size="md"><Icon name="lucide:plus" aria-hidden="true" />Add item</Button>
<Button size="lg"><Icon name="lucide:plus" aria-hidden="true" />Add item</Button>- The gap is the component’s.
gap-[0.5em]spaces the icon from the label, so never addmr-2orml-1at the call site — that stacks on top of the gap and desynchronizes from the size ramp. - Decorative icons take
aria-hidden="true". The label already names the action; an unhidden icon makes screen readers announce it twice. The exception is an icon-only button, where the icon is the label and the button needsaria-label. - One icon per side, at most. A leading and a trailing icon together is legitimate — a menu trigger is the usual case — but three is a toolbar wearing a button’s clothes.
shrink-0is already applied, so an icon holds its size when the label wraps or the button is squeezed inside a narrow flex row.
Icon only
iconOnly swaps the side padding for w-[3em], which is the height the base already
sets — so the box is square, and it stays square through a retuned type scale because
both dimensions are the label’s own em.
---
import { Icon } from 'astro-icon/components';
import Button from '@/components/ui/Button.astro';
import ButtonLink from '@/components/ui/ButtonLink.astro';
---
<Button variant="outlined" iconOnly aria-label="Search">
<Icon name="lucide:search" />
</Button>
<Button variant="ghost" iconOnly aria-label="More actions">
<Icon name="lucide:ellipsis-vertical" />
</Button>
<Button variant="primary" iconOnly aria-label="Add item">
<Icon name="lucide:plus" />
</Button>
<Button variant="danger" iconOnly aria-label="Delete project">
<Icon name="lucide:trash-2" />
</Button>
<Button variant="outlined" iconOnly disabled aria-label="Undo">
<Icon name="lucide:undo-2" />
</Button>
<ButtonLink href="https://example.com" variant="ghost" iconOnly aria-label="Open documentation"
target="_blank" rel="noopener noreferrer">
<Icon name="lucide:external-link" />
</ButtonLink>aria-label is not optional here. With no text node, a button that omits it has no
accessible name at all — a screen reader announces “button” and nothing else, which fails
WCAG 4.1.2. Label the
action, not the glyph: aria-label="Delete project", never aria-label="Trash icon".
The icon drops its aria-hidden in this one case. Everywhere else the label already names
the action and an exposed icon makes it announce twice; here aria-label supplies the name
outright, so nothing is doubled either way.
It is a swap, not an override. px-0 and px-[1.286em] are the same utility at the
same specificity, so a call site that adds px-0 by hand is betting on stylesheet order.
The prop emits one string or the other.
- Only for actions that are instantly recognizable — search, close, more, add, delete. An ambiguous glyph with no label is a guessing game, and the tooltip only shows up for people who can hover.
- A tooltip does not replace
aria-label— hover is not available on touch and not reachable by keyboard. If you add atitle, it is on top of the label, not instead of it. - Touch targets: the box is 36px at
sm, 42px atmd, 48px atlg. All three clear WCAG 2.5.8’s 24px minimum; onlylgreaches the 44px AAA target, so prefermdorlgon touch surfaces and keepsmfor dense desktop toolbars where the row itself is small. - Never a lone icon-only primary. A filled square with no label is the most prominent thing on a page and the least self-explanatory; if the action deserves that weight, it deserves a word.
Colors
variant is weight. color is which brand color carries that weight — a second, independent axis. They used to be one, which is why a five-variant set could offer no way at all to make a secondary-colored button: the word secondary had been spent naming an emphasis level.
<Button color="secondary">Secondary</Button>
<Button color="accent">Accent</Button>
<Button variant="outlined" color="primary">Outlined primary</Button>
<Button variant="outlined" color="accent">Outlined accent</Button>
<Button variant="ghost" color="primary">Ghost primary</Button>color behaves differently per weight, and the asymmetry is the contract rather than an oversight. The palette guarantees each color as a fill — primary and secondary carry white ink, accent carries dark ink — and guarantees much less about a color as ink. Color says secondary is large-text-only unless it clears 4.5:1, and accent is never used as text on a light ground. Only primary is contracted to reach 4.5:1 against the page.
| Weight | What color changes | Ink |
|---|---|---|
primary | The fill | Whatever the palette promises for that color — white for primary and secondary, dark for accent |
outlined | The border and the hover wash | text-primary for color="primary"; text-dark for the others |
ghost | The hover wash only | Same rule — there is no border or fill to color |
danger | Nothing. Its color is the message | White |
Omit color and you get the neutral default: a plain brand fill for primary, and a border-dark/22 outline or a text-dark/70 ghost for the other two. A supporting action beside a primary one should not compete with it — ask for color="primary" when you want it to.
On a dark band, colored outlined and ghost fall back to the neutral white treatment. Every ink guarantee in the palette is measured against bg-light and none of them carry over. Colored fills keep their color there, because a fill brings its own ground with it.
Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>Size sets the label, and the box follows from it. Height is 3em, side padding 1.286em, icon gap 0.5em, icon 1.15em — all multiples of the button’s own font size, so every proportion is constant across the ramp by construction.
| Size | Label role | Font | Height | Padding | Gap | Icon |
|---|---|---|---|---|---|---|
sm | text-control-sm | 12.1px | 36.3px | 15.6px | 6.1px | 13.9px |
md | text-control | 14px | 42px | 18px | 7px | 16.1px |
lg | text-control-lg | 16px | 48px | 20.6px | 8px | 18.4px |
The px column is what the shipped scale produces. Retune base or ratio in the theme builder and every number in it moves together — the ratios do not.
Why em, and what it replaced
The old ramp ran heights 32/40/48 and fonts 12/14/16. Both are linear, but with different offsets, which makes height = 4 × font − 16 — affine, not proportional. With an offset in there, no label-relative ratio can hold:
| Ratio | sm | md | lg | Drift |
|---|---|---|---|---|
| height / font | 2.667 | 2.857 | 3.000 | 12.5% |
| padding / font | 1.000 | 1.143 | 1.250 | 25.0% |
| gap / font | 0.333 | 0.571 | 0.500 | 71.4% |
So sm read cramped and lg read loose — the box was growing faster than the text it held. The one ratio that did hold, side padding to vertical padding at ~1.22, held by accident rather than by decision.
Stating the box in em fixes it at the source, and it is the only form that survives a themed type scale: a px height would silently re-break the proportion the next time someone retuned base or ratio.
Touch targets. All three sizes clear the 24px minimum of WCAG 2.5.8 Target Size (Minimum). Only lg reaches the 44px of WCAG 2.5.5 (AAA) and the Apple HIG recommendation — use lg where that matters, or where a control is the primary action on a touch device.
On inverse surfaces
Default variants assume a light surface (bg-light). On dark or inverted regions (bg-dark or similar), prefer a parent with data-on-inverse so fills, borders, and text inherit inverse semantic tokens automatically. Use appearance="inverse" only as an escape hatch when the button cannot inherit surface context.
ComponentPreview sets data-on-inverse on the canvas when background="dark", so buttons inside pick up inverse styling without extra props.
<Button variant="primary">Primary</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>On a dark ground primary inverts to a bg-light fill — the brand color is not guaranteed to carry white ink against bg-dark, and a fill that fails there fails silently. danger does not invert: it is the one variant whose color is the message, so it stays bg-danger on both grounds. ghost keeps full text-white rather than stepping down to the /58 muted rung; dimming the one borderless control on an inverse band reads as disabled, doubly so once disabled:opacity-40 lands on top of it.
States
Every variant must be defined in all of these states:
| State | Notes |
|---|---|
| Default | Normal resting state |
| Hover | Subtle background shift; must feel related to active |
| Active / pressed | Slightly deeper than hover |
| Focus visible | Always clear and never hidden |
| Disabled | Reduced emphasis, but still readable |
| Loading | Preserves button width to avoid layout shift |
| Selected | Only where toggle behavior exists |
<Button variant="primary" disabled>Primary</Button>
<Button variant="outlined" disabled>Outlined</Button>
<Button variant="ghost" disabled>Ghost</Button>
<Button variant="danger" disabled>Danger</Button>- Disabled — opacity 40%, cursor
not-allowed. Usedisabledattribute, notaria-disabled, unless you need the element to remain focusable. - Focus — visible ring via
:focus-visibleonly. Ring is 2px solid, offset 2px from the element edge. - Hover / Active — background shifts using component tokens; no border or size changes.
Behavior
| Behavior | What it does |
|---|---|
| Default | A single-fire action — the most common case |
| Loading | Replaces the label or adds a spinner; the width must stay stable |
| Toggle | Holds an on/off or selected state until toggled again |
| Grouped / segmented | Several related actions or views side by side — grid/list, day/week/month, bold/italic/underline |
ButtonLink
ButtonLink renders an <a> element with the same visual treatment as Button. Switching between the two is a one-word change — only the rendered element and the href prop differ.
| Control | Use when |
|---|---|
Button | The control acts — submits a form, opens a dialog, triggers a mutation, or changes local state |
ButtonLink | The control navigates — follows a link, opens a new page, or changes the browser location |
Never use a <button> for navigation or an <a> for actions. Screen readers and keyboard users rely on the semantic distinction.
<ButtonLink href="#" variant="primary">Primary</ButtonLink>
<ButtonLink href="#" variant="outlined">Outlined</ButtonLink>
<ButtonLink href="#" variant="ghost">Ghost</ButtonLink>
<ButtonLink href="#" variant="danger">Danger</ButtonLink>Emphasis maps exactly as it does for Button, read for navigation: primary is the single most important navigational call-to-action, outlined supports it, and ghost is the everyday nav and pagination link. danger is rare on a link and usually wrong — navigating somewhere is not itself destructive. Navigate with outlined and put the danger Button on the step that actually destroys something.
Sizes and inverse-surface behavior are identical to Button — both read the same class map, and a ButtonLink inside [data-on-inverse] inherits inverse styling the same way.
ButtonLink accessibility
- Descriptive link text — the visible label (or
aria-label) must describe the destination or outcome. Vague text like “click here” or “read more” is meaningless out of context for screen reader users. hrefis required — omitting it produces a non-interactive anchor that assistive technology cannot activate as a link. If no destination exists yet, use aButtonwith a click handler instead.- External links — when using
target="_blank", signal the context change in the visible text or anaria-labelso users are not surprised. - Never swap with Button — swapping
<a>and<button>breaks the semantic contract keyboard and screen reader users depend on.
Props
Button
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | primary | outlined | ghost | danger | primary | Controls visual emphasis. danger is primary in the danger color, not a fourth emphasis level |
color | primary | secondary | accent | — | Which brand color carries the weight. Omit for the neutral default. Ignored by danger |
size | sm | md | lg | md | Controls height, padding, and font size |
appearance | default | inverse | default | Prefer inherited inverse context via [data-on-inverse]; use inverse as an escape hatch |
type | button | submit | reset | button | Set to submit inside <form> elements |
disabled | boolean | false | Disables interaction and reduces opacity |
class | string | — | Appended to the root element’s class list |
All other attributes (e.g. aria-label, data-*, id) are forwarded to the underlying <button> element.
ButtonLink
| Prop | Type | Default | Notes |
|---|---|---|---|
href | string | — (required) | Destination URL; forwarded to the <a> element |
variant | primary | outlined | ghost | danger | primary | Matches Button variants exactly |
color | primary | secondary | accent | — | Which brand color carries the weight. Omit for the neutral default. Ignored by danger |
size | sm | md | lg | md | Controls height, padding, and font size |
appearance | default | inverse | default | Prefer inherited inverse context via [data-on-inverse]; use inverse as an escape hatch |
class | string | — | Appended to the root element’s class list |
All other attributes (e.g. target, rel, aria-label, data-*) are forwarded to the underlying <a> element. For external links always include target="_blank" rel="noopener noreferrer".
Where the styles live
A button is a composition of utilities Tailwind already ships, and the component
is what keeps that composition consistent. The class strings live in
src/components/ui/button-classes.ts — the
single map both Button and ButtonLink read from.
| Aspect | Utilities | Notes |
|---|---|---|
| Geometry | h-[3em] gap-[0.5em] plus px-[1.286em], or w-[3em] px-0 when iconOnly | Shared by every size — the box is a multiple of the label |
| Icons | [&_svg]:size-[1.15em] [&_svg]:shrink-0 | Scale with the label; call sites pass no size |
Size sm | text-control-sm | Label only |
Size md | text-control | Label only, the default |
Size lg | text-control-lg | Label only |
| Shape | border-[1.5px] + the theme’s control radius | Width is structural and shared by every weight; the radius is a convention, not a constant — see below |
| Motion | The theme’s transition | transition-colors only, never size or position |
| Elevation | The theme’s resting shadow, on fills only | primary and danger; a shadow needs an opaque object above it |
| Focus | focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary | Brand color, per the focus convention |
| Disabled | disabled:opacity-40 disabled:cursor-not-allowed | Hover and active are suppressed with not-disabled: |
Shape, motion and elevation are not decided here. They come from
controlClasses() in theme-rules.mjs — the same function the generated
theme-rules.md is written from — so a theme that says “controls are
rounded-full” gets buttons that are actually rounded-full. Buttons used to
hard-code rounded-lg and duration-150, neither of which any convention option
emits, which made a button the one thing on the page ignoring the shape and
motion the theme had just been told to use.
Variant colors come from the same six brand colors as everything else. primary
is bg-primary text-white, hovering to bg-primary/90. outlined and ghost
are transparent — not bg-light, so they sit on a card as readily as on the page
— and their hover and active washes are the bottom two rungs of the ink ladder,
bg-dark/12 then bg-dark/22, so they deepen in the same steps a hairline and a
strong border already use. danger is the exception that darkens instead of
fading: bg-danger/90 composites toward the page and reads lighter, the wrong
direction for a warning, so it hovers to --color-danger-hover.
Because the palette is aliased through var(), every one of those alpha steps
compiles to a live color-mix() and follows a theme change at runtime. See
Color.
Font weight and leading come from the control type roles, not from the button — 500 and 1 on the shipped scale, both retunable with the rest of the type.
If you change the sizes:
- Keep a fixed height per size — never let line-height define visual height.
- Horizontal padding must exceed vertical internal spacing.
- Pick one radius model for the whole system and stay with it; avoid ad hoc mixed shapes on the same surface.
Usage rules
- One primary per area — each view section should have at most one
primarybutton. Supporting actions step down tooutlined, thenghost. - Label required — every button must have visible text or an
aria-label. Icon-only buttons must includearia-label. - Sentence case — button labels use sentence case (“Save changes”, not “SAVE CHANGES” or “Save Changes”).
- Active language — labels describe the action, not the result (“Delete project”, not “Yes” or “Confirm”).
- Short and verb-led — prefer Save, Continue, Delete, View details. Avoid “Click here”, “Submit form now”, “Proceed to the next page”.
- Danger requires confirmation — always pair a
dangerbutton with a confirmation dialog before executing a destructive action.
Presets
A preset applied to this component may adjust colors (background, border, text per variant and state), typography (size, weight), radius, and density (height and padding utilities).
A preset must not change which variant is called primary, outlined, or danger, what the states mean or when they apply, the size names, or any accessibility requirement. See System Model for the full preset rules.
Out of scope
These may exist as product- or marketing-level exceptions, but not as base variants: gradient CTAs, hero-only buttons, floating action buttons (unless the product model requires one), social media buttons, decorative pill-only CTAs, and one-off campaign treatments.
Do not use when
- The action navigates to a new page — use
ButtonLinkinstead - The control is a toggle that holds an on/off state — use a
ToggleorSwitch - You need more than one primary action in a single view — reconsider the hierarchy