Core
Primary is the anchor
Settings

How the five derived colors are placed around the anchor.

Theme builder

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.

VariantWeightUse whenTypical labels
primaryFilledThe single most important action in a viewSave changes, Continue, Publish
outlinedBorderedSupporting actions that still need a visible affordance at restCancel, Back, Preview, Edit
ghostNeitherInline, toolbar and navigation actions where a border would only add noiseLearn more, Dismiss, View details
dangerFilledDestructive or irreversible actions, with real consequence or data lossDelete, 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.

StructureUse when
Text onlyThe standard form — use unless there is a reason not to
Leading icon + textThe icon speeds up scanning or reinforces the action’s meaning
Trailing icon + textThe icon signals direction or a follow-up (external link, expand, arrow)
Icon onlyThe action is common and instantly recognizable — iconOnly, and always with aria-label
Split buttonOne 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 what iconOnly sets. 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.

Documentation

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.

  • The gap is the component’s. gap-[0.5em] spaces the icon from the label, so never add mr-2 or ml-1 at 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 needs aria-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-0 is 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.

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 a title, it is on top of the label, not instead of it.
  • Touch targets: the box is 36px at sm, 42px at md, 48px at lg. All three clear WCAG 2.5.8’s 24px minimum; only lg reaches the 44px AAA target, so prefer md or lg on touch surfaces and keep sm for 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.

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.

WeightWhat color changesInk
primaryThe fillWhatever the palette promises for that color — white for primary and secondary, dark for accent
outlinedThe border and the hover washtext-primary for color="primary"; text-dark for the others
ghostThe hover wash onlySame rule — there is no border or fill to color
dangerNothing. Its color is the messageWhite

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

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.

SizeLabel roleFontHeightPaddingGapIcon
smtext-control-sm12.1px36.3px15.6px6.1px13.9px
mdtext-control14px42px18px7px16.1px
lgtext-control-lg16px48px20.6px8px18.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:

RatiosmmdlgDrift
height / font2.6672.8573.00012.5%
padding / font1.0001.1431.25025.0%
gap / font0.3330.5710.50071.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.

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:

StateNotes
DefaultNormal resting state
HoverSubtle background shift; must feel related to active
Active / pressedSlightly deeper than hover
Focus visibleAlways clear and never hidden
DisabledReduced emphasis, but still readable
LoadingPreserves button width to avoid layout shift
SelectedOnly where toggle behavior exists
  • Disabled — opacity 40%, cursor not-allowed. Use disabled attribute, not aria-disabled, unless you need the element to remain focusable.
  • Focus — visible ring via :focus-visible only. Ring is 2px solid, offset 2px from the element edge.
  • Hover / Active — background shifts using component tokens; no border or size changes.

Behavior

BehaviorWhat it does
DefaultA single-fire action — the most common case
LoadingReplaces the label or adds a spinner; the width must stay stable
ToggleHolds an on/off or selected state until toggled again
Grouped / segmentedSeveral related actions or views side by side — grid/list, day/week/month, bold/italic/underline

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.

ControlUse when
ButtonThe control acts — submits a form, opens a dialog, triggers a mutation, or changes local state
ButtonLinkThe 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.

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.

  • 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.
  • href is required — omitting it produces a non-interactive anchor that assistive technology cannot activate as a link. If no destination exists yet, use a Button with a click handler instead.
  • External links — when using target="_blank", signal the context change in the visible text or an aria-label so 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

PropTypeDefaultNotes
variantprimary | outlined | ghost | dangerprimaryControls visual emphasis. danger is primary in the danger color, not a fourth emphasis level
colorprimary | secondary | accentWhich brand color carries the weight. Omit for the neutral default. Ignored by danger
sizesm | md | lgmdControls height, padding, and font size
appearancedefault | inversedefaultPrefer inherited inverse context via [data-on-inverse]; use inverse as an escape hatch
typebutton | submit | resetbuttonSet to submit inside <form> elements
disabledbooleanfalseDisables interaction and reduces opacity
classstringAppended to the root element’s class list

All other attributes (e.g. aria-label, data-*, id) are forwarded to the underlying <button> element.

PropTypeDefaultNotes
hrefstring— (required)Destination URL; forwarded to the <a> element
variantprimary | outlined | ghost | dangerprimaryMatches Button variants exactly
colorprimary | secondary | accentWhich brand color carries the weight. Omit for the neutral default. Ignored by danger
sizesm | md | lgmdControls height, padding, and font size
appearancedefault | inversedefaultPrefer inherited inverse context via [data-on-inverse]; use inverse as an escape hatch
classstringAppended 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.

AspectUtilitiesNotes
Geometryh-[3em] gap-[0.5em] plus px-[1.286em], or w-[3em] px-0 when iconOnlyShared by every size — the box is a multiple of the label
Icons[&_svg]:size-[1.15em] [&_svg]:shrink-0Scale with the label; call sites pass no size
Size smtext-control-smLabel only
Size mdtext-controlLabel only, the default
Size lgtext-control-lgLabel only
Shapeborder-[1.5px] + the theme’s control radiusWidth is structural and shared by every weight; the radius is a convention, not a constant — see below
MotionThe theme’s transitiontransition-colors only, never size or position
ElevationThe theme’s resting shadow, on fills onlyprimary and danger; a shadow needs an opaque object above it
Focusfocus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primaryBrand color, per the focus convention
Disableddisabled:opacity-40 disabled:cursor-not-allowedHover 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 primary button. Supporting actions step down to outlined, then ghost.
  • Label required — every button must have visible text or an aria-label. Icon-only buttons must include aria-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 danger button 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 ButtonLink instead
  • The control is a toggle that holds an on/off state — use a Toggle or Switch
  • You need more than one primary action in a single view — reconsider the hierarchy