Skip to content

Button

Triggers an action in place — submit, save, confirm, open a dialog.

Stablev0.4.2added in v0.1.0@garn/ui/button
On this page

Default

Default button.

Appearance

Fill / emphasis (Button's named-exception axis). Pair with `tone` for color intent.

Emphasis

Emphasis steps from solid to quiet — `default` for the primary action, `soft` for a common second-tier one, `outline`/`ghost` for tertiary and dense surfaces. Layer `tone` on any of them.

Sizing

Control height rung; `icon-*` sizes are square (icon-only).

Spacing

The same control rendered across garn's density modes — compact, default, and spacious.

States

Interactive states, toggled live.

Tone

Color intent, applied to the default/soft/outline/ghost fills (`link` is tone-inert).

Justify

How the leading/label/trailing run is placed once the button is wider than its content. `between` pushes the slots to the edges (the nav-row / disclosure look); `start` left-aligns the run. No effect at hug width.

FullWidth

Fills the inline axis of the container instead of hugging its content — releasing the base `shrink-0`. Usually paired with `justify`.

Button with icon

Pair the label with an icon to reinforce meaning, or drop the label entirely for compact actions.

Icon Before

A leading icon clarifies the action; the optical trim keeps it centered.

Icon After

A trailing icon signals direction or progression.

Icon Only

Square `icon-*` sizes — always pass an aria-label.

Icon sizes

Async

Async State

The full lifecycle: paced spinner → check/cross → self-reset via `onStateChange`. Each step is announced.

Form Status

React 19 `useFormStatus` driving `loading` from the enclosing form's pending state.

Label Swap

Width-preserving label swap (“Copy” → “Copied”) — a grid stack, so the button never resizes.

Layout

Full Width

`fullWidth` with each `justify` value — the axis only becomes visible once the button is wider than its content.

Description

The compound shape: a de-emphasized second line that joins the accessible name.

Content

Shortcut Hint

A Kbd hint in the trailing slot, aria-hidden so the accessible name stays the bare verb.

Icon Nudge

A trailing arrow that nudges on hover — motion-safe gated.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add button
First time? Set up garn in your project
Registry
button
Deps
@radix-ui/react-slotclass-variance-authority
Registry deps
utilsuse-announceuse-isomorphic-layout-effect

Import

import { Button } from "@garn/ui/button";

Props

The component's public props and their types.

fullWidth
Description
No description.
Type
boolean
Default
false
justify
Description
No description.
Type
"between" | "center" | "start"
Default
center
size
Description
No description.
Type
"icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
md
tone
Description
No description.
Type
"brand" | "danger" | "neutral" | "success" | "warning"
Default
neutral
variant
Description
No description.
Type
"default" | "ghost" | "link" | "outline" | "soft"
Default
default
asChild
Description
Render as the single child element (via Radix `Slot`) instead of a native `<button>` — for wrapping a link or a menu trigger while keeping the styling.
Type
boolean
Default
false
description
Description
Secondary, de-emphasized line under the label — the compound-button shape used for dense choice UIs and onboarding CTAs. Stacks the label into a column and lets the button grow taller than its size rung. Requires a label; not valid on the icon-only sizes. It renders as ordinary content, so it JOINS the accessible name after the label ("Share publicly, Anyone with the link can view") — which is the point: the choice a compound button offers is both lines, and a screen reader user picking between them needs both. Keep it to a short phrase; pass `aria-label` if the spoken name should stay the bare verb.
Type
React.ReactNode
leading
Description
Content placed before the label — an icon, badge, counter, kbd hint, etc. Fixed width (never shrinks); only the label truncates.
Type
React.ReactNode
loading
Description
Shows a spinner and makes the button inert while keeping it FOCUSABLE (`aria-disabled` + an internal activation guard, never native `disabled` — that would drop focus to `<body>` mid-flight). Also sets `aria-busy` and announces to the shared live region. Sugar for `state="loading"`. Width is preserved — the resting content is hidden in place and the spinner is centered over it, so there's no layout shift.
Type
boolean
Default
false
loadingText
Description
Optional label shown while `loading` (e.g. "Saving…"). When provided the spinner sits inline before this text instead of overlaying the content.
Type
React.ReactNode
onStateChange
Description
Called when the button asks for a different `state` — today only to request `"idle"`, once a `success`/`error` resolution has been on screen long enough to read. Wire it to the same setter that drives `state` and the reset chore disappears: ```tsx <Button state={state} onStateChange={setState} onClick={run}>Publish</Button> ``` Leave it off and the resolution holds until you clear it yourself. The button never changes `state` on its own — it only asks, so the value stays yours.
Type
(state: ButtonState) => void
state
Description
Full async lifecycle, for actions that should resolve visibly rather than just stop spinning: `success` / `error` swap the spinner for a check or cross that draws in, and announce completion. Controlled — pair it with `onStateChange` to have the resolution clear itself.
Type
ButtonState
trailing
Description
Content placed after the label — an icon, badge, kbd hint, etc. Fixed width (never shrinks); only the label truncates.
Type
React.ReactNode

Plus 290 inherited native <button> attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="button"][data-slot="button-leading"][data-slot="button-label"][data-slot="button-label-stack"][data-slot="button-description"][data-slot="button-trailing"][data-slot="button-spinner"][data-slot="button-status"]

States

SelectorState
:hoverhover
:activeactive
:focus-visiblefocus-visible
[disabled]disabled
[data-loading]loading
[data-state="success"]success
[data-state="error"]error
[data-full-width]full-width
PropertyTokenTier
height--garn-control-h-mdsemantic · air
paddingInline--garn-control-px-mdsemantic · air
iconInset--garn-control-icon-insetsemantic · identity
ring--garn-ringbrandablesemantic

Private instance vars (never externalize): --btn-fill--btn-px--btn-ps--btn-pe

When to use

An in-place action: submit, save, confirm, delete, or open a dialog/menu.

Reach for something else when

  • Navigating to a URL or route — use a link, or <Button asChild> wrapping an anchor.

Overview

Button is the workhorse of every interface — the control a person reaches for to make something happen right now: submit a form, save a draft, confirm a delete, open a dialog. Because it shows up everywhere, its job is to make the emphasis of an action legible at a glance. Button splits that into two orthogonal axes: variant sets the fill / emphasis (solid default for the one action you most want taken, soft/ghost for secondary ones) and tone sets the color intent (brand, danger, success, warning) — so <Button tone="danger"> is a solid destructive action and <Button variant="outline" tone="danger"> a quieter one. size and the density tokens keep it aligned with the fields and controls around it.

Reach for Button when the result is an action in the page, not a destination. The moment the click should change the URL, it's a link — render it as one (<Button asChild><a …>) so the browser, middle-click, and screen readers all treat it correctly.

Guidelines

Establish one clear primary action per view. A screen with five filled buttons has no primary action at all. Pick the single most important action, give it the solid variant (with tone="brand" for the CTA), and demote everything else to soft, ghost, or outline. This is the fastest way to make a layout feel calm and decisive.

Let emphasis match consequence. A destructive action (tone="danger") should look weightier than a benign one, and a rarely-used action shouldn't compete with the main one. Keep tone and fill orthogonal: raise or lower the fill to change emphasis, set the tone to change meaning. When two actions sit together — Save and Cancel — fill only the affirmative and keep the dismissive quiet.

Pass icons through leading/trailing, never inline. The slot props apply the optical-padding trim so an icon+label stays balanced across every size; hand-placing an icon and padding with px-* collides with the --btn-px system. For icon-only buttons, always supply an aria-label — the dev-time probe will warn you if you forget.

Let async work stay honest. Set loading for a pending action and the button keeps its width, keeps its place in the tab order, and announces itself — it is never natively disabled, because that drops focus to <body> and strands whoever pressed it. The spinner is paced rather than immediate: nothing appears for the first ~200ms, so a fast request never flashes, and once shown it stays ~300ms so a slow-ish one never blinks. When the outcome is worth showing, use state instead of loading and the spinner resolves into a check or cross; wire onStateChange to the same setter and the button asks to return to idle on its own once the glyph has been readable.

Best practices

Do
  • Use one primary (or default) high-emphasis button per view as the main action.
  • Give icon-only buttons an aria-label.
  • Pass icons via leading/trailing so the optical padding trim applies.
  • Use loading for work in flight — it keeps the button focusable and announced.
  • Pair state with onStateChange when you want the success/error glyph to clear itself.
  • Pair fullWidth with justify when a wide button should read as a row rather than a centered label.
Don't
  • Don't use Button for navigation.
  • Don't place two filled high-emphasis buttons side by side — demote one to soft/ghost.
  • Don't hand-pad with px-* utilities; it collides with the --btn-px / optical-trim system.
  • Don't set disabled to express pending — that drops focus to <body> and says nothing to AT. loading is the pending state.
  • Don't expect justify to do anything at hug width; it only bites once the button is wider than its content.

Content guidelines

  • Lead with a verb that names the outcome — “Save changes”, “Delete project”, “Send invite”.
  • Use sentence case and keep the label to one or two words; let the surrounding context carry the detail.
  • Avoid “Click” / “Tap” and vague labels like “OK” or “Submit” — say what actually happens.
  • Match the label to the consequence: a destructive button should name the destruction (“Delete”, not “Yes”).
  • Keep paired actions parallel — “Save” / “Cancel”, not “Save” / “Never mind”.

Troubleshooting

variant="brand", "danger", "danger-outline", "success", or "warning" doesn't type-check.

Cause. Those merged tone into variant. O3 split the axes: variant is fill/emphasis, tone is color intent.

Fix. Move color to tone: brand→tone="brand", danger→tone="danger", danger-outline→variant="outline" tone="danger", success/warning→tone="success"/"warning".

Icon looks off-center next to the label.

Cause. Optical trim fires per-occupied side via --btn-ps/--btn-pe.

Fix. Pass leading/trailing slots; never hand-pad with px-* utilities.

Button width jumps when entering the loading state.

Cause. Loading overlays a hidden copy to preserve width (when no loadingText).

Fix. Rely on the default overlay; only pass loadingText when you want the inline spinner.

A pending button is skipped by the Tab key, or focus jumps to the top of the page when loading starts.

Cause. disabled={isPending} was used for the pending state. A natively disabled element leaves the tab order, and the browser drops focus to <body>.

Fix. Use loading={isPending} (or state="loading"). It sets aria-disabled + blocks activation while keeping the button focusable and announced. Reserve disabled for genuinely unavailable actions.

The success/error glyph never goes away — the button is stuck on its check mark.

Cause. state is controlled and nothing cleared it. The button never mutates state on its own; it only ASKS.

Fix. Wire onStateChange to the same setter that drives state (<Button state={s} onStateChange={setS}>), and it requests "idle" ~1.2s after the glyph appears. Leave the prop off and the resolution holds deliberately.

Nothing happens for the first fraction of a second after setting loading — the spinner seems broken.

Cause. Deliberate pacing: the spinner waits ~200ms before appearing, then stays ~300ms once shown.

Fix. Nothing to fix — it's what keeps fast requests from flashing and slow ones from blinking. Don't add your own delay on top.

data-state is missing on a resting button, so a [data-state] selector never matches.

Cause. Only the non-idle values are reflected — deliberately, so a wrapper can own the attribute (Radix writes open/closed onto a DropdownMenuTrigger asChild Button).

Fix. Target the value you mean ([data-state="loading"]), and treat 'attribute absent' as idle.

Voice control can no longer activate a button by its visible label after adding description.

Cause. The description is ordinary content, so it joins the accessible name after the label — by design: the choice a compound button offers is both lines.

Fix. Accept the longer name, or pass an explicit aria-label if the spoken name must stay the bare verb.

justify="between" looks like it does nothing.

Cause. The axis only shows once the button is wider than its content; at hug width all three values render identically.

Fix. Combine it with fullWidth (or a grid/flex cell that stretches the button).

Theming breaks after setting --btn-fill globally.

Cause. --btn-fill is instance-scoped on purpose; a :root value pre-resolves and kills the shadow recipe.

Fix. Never declare --btn-fill in :root or expose it as a theme variable.

Accessibility

Role
button
ARIA APG
button
Focus
outline-hidden + 3px box-shadow ring in --btn-fill darkened 18% (hugs the button, no offset).

Accessibility requirements

warn

Provide aria-label, aria-labelledby, title, or sr-only text.

when size starts with 'icon' && !asChild && no accessible name (aria-label/labelledby/title/sr-only text)

error

A pending button keeps aria-disabled='true' and NO native disabled attribute, so it stays in the tab order and focus does not move when loading flips true while it is focused.

when loading || state === 'loading'

error

Clicking or pressing Enter/Space on a pending button does not fire onClick.

when loading || state === 'loading'

error

A plain disabled button keeps the native disabled attribute — loading is the only state that switches to aria-disabled.

when disabled && !loading

error

Pending announces politely via the shared live region on the same show-delay as the spinner (so a sub-200ms action isn't announced then overwritten before AT reaches it); success announces politely and error assertively, on the transition itself.

when loading || state === 'success' || state === 'error'

error

The success/error glyph swap has no transition — the finished glyph is shown, never a half-drawn one.

when prefers-reduced-motion && (state === 'success' || state === 'error')

warn

`description` is the SECONDARY line — give it a label to sit under.

when description != null && no text label in children

warn

`description` needs a visible label to sit under; drop it or move to a text size.

when description != null && size starts with 'icon'

Keyboard

EnterSpaceActivate the button.
EnterSpaceWhile loading: activation is suppressed (the keydown default is prevented, which is what stops a pending submit from posting its form), but the event still forwards — a container that owns a keyboard model, like a roving toolbar, keeps reading its own keys off this handler.
A2.1.1KeyboardAA1.4.11Non-text ContrastAA2.4.7Focus VisibleAA2.5.8Target Size (Minimum)
  • While loading: aria-busy + aria-disabled are set and activation is blocked, but the button keeps the native focusability of a real <button> — it stays in the tab order and focus never moves. Both attributes are OR-ed with any incoming value, so a caller's own aria-disabled is preserved.
  • `disabled` is independent of `loading` and keeps full native semantics (attribute set, removed from the tab order).
  • `data-state` is reflected only for the non-resting values — an idle button leaves the attribute FREE, which is what lets a wrapper own it (Radix writes open/closed onto a `DropdownMenuTrigger asChild` Button, and that drives the split button's chevron).
  • The spinner and the success/error glyphs are aria-hidden. State changes reach AT through the shared live region instead: polite while pending and on success, assertive on error.
  • `description` renders as ordinary content, so it JOINS the accessible name after the label ('Share publicly, Anyone with the link can view').

Choosing emphasis

Button has two orthogonal axes. variant sets the fill / emphasis (how loud the control is), and tone sets the color intent (what it means). Most button mistakes are emphasis mistakes, not styling ones. A view should have exactly one action that looks like the thing to do — give that one a filled variant with tone="brand" and demote everything else. The further an action is from the main path, the quieter it should read:

  • default (solid) — the single primary action ("Save", "Continue"); add tone="brand" for the emphasized CTA.
  • soft / secondary — common but secondary actions that still need a fill.
  • outline / ghost — tertiary actions, toolbars, and dense surfaces.
  • link — an action that reads as text (an inline "Learn more").

Layer tone (neutral · brand · danger · success · warning) onto any of the fills: tone="danger" on a solid button for a primary destructive action, or variant="outline" tone="danger" to de-emphasize it while keeping the red signal.

When two buttons sit together, fill only the affirmative one. Save / Cancel becomes a filled Save beside a ghost or outline Cancel — never two filled buttons competing for the eye.

Composition with asChild

Button is an action, not a destination. When a click should change the route, render a real anchor and let Button style it — asChild hands the variant and size classes to the child element, so the browser, middle-click, and screen readers all treat it as a link:

code
<Button asChild variant="link">
  <a href="/pricing">See pricing</a>
</Button>

asChild hands both the variant and tone classes to the child.

The same pattern wraps a next/link, a menu item, or any element that should look like a button while keeping its own semantics.

The loading model

loading does three things at once: it shows a spinner, sets aria-busy, and disables interaction — without shifting layout. The resting content is hidden in place and the spinner is centered over its footprint, so the button keeps its width and the row never reflows. Pass loadingText when the wait is long enough to deserve a word ("Saving…"); the spinner then sits inline before the text.

Because loading already implies disabled, you don't need both. In asChild mode the library can't disable a consumer element, so guard the action yourself.

Icons and optical balance

Pass icons through the leading / trailing slots rather than dropping them inside the label. The slots apply the optical-padding trim that keeps an icon + label visually centered across every size, and they stay fixed-width so only the label truncates. For an icon-only button, set an aria-label — the dev-time probe warns when one is missing, because an unlabeled icon button is invisible to assistive tech.