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.
Triggers an action in place — submit, save, confirm, open a dialog.
@garn/ui/buttonDefault button.
Fill / emphasis (Button's named-exception axis). Pair with `tone` for color intent.
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.
Control height rung; `icon-*` sizes are square (icon-only).
The same control rendered across garn's density modes — compact, default, and spacious.
Interactive states, toggled live.
Color intent, applied to the default/soft/outline/ghost fills (`link` is tone-inert).
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.
Fills the inline axis of the container instead of hugging its content — releasing the base `shrink-0`. Usually paired with `justify`.
The full lifecycle: paced spinner → check/cross → self-reset via `onStateChange`. Each step is announced.
React 19 `useFormStatus` driving `loading` from the enclosing form's pending state.
Width-preserving label swap (“Copy” → “Copied”) — a grid stack, so the button never resizes.
`fullWidth` with each `justify` value — the axis only becomes visible once the button is wider than its content.
The compound shape: a de-emphasized second line that joins the accessible name.
A Kbd hint in the trailing slot, aria-hidden so the accessible name stays the bare verb.
A trailing arrow that nudges on hover — motion-safe gated.
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.
npx garn-ui add buttonbutton@radix-ui/react-slotclass-variance-authorityutilsuse-announceuse-isomorphic-layout-effectImport
import { Button } from "@garn/ui/button";The component's public props and their types.
fullWidthbooleanfalsejustify"between" | "center" | "start"centersize"icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"mdtone"brand" | "danger" | "neutral" | "success" | "warning"neutralvariant"default" | "ghost" | "link" | "outline" | "soft"defaultasChildbooleanfalsedescriptionReact.ReactNodeleadingReact.ReactNodeloadingbooleanfalseloadingTextReact.ReactNodeonStateChange(state: ButtonState) => voidstateButtonStatetrailingReact.ReactNodePlus 290 inherited native <button> attributes.
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
| Selector | State | Description |
|---|---|---|
:hover | hover | |
:active | active | Darkens the fill, and sinks the bevel on the solids. The button does NOT move — no sink, no squash: library-wide, a garn control never shifts under the pointer. |
:focus-visible | focus-visible | 3px ring in --btn-fill. |
[disabled] | disabled | The NATIVE disabled attribute — dims, flattens the shadow, blocks pointer events, and leaves the tab order. Unaffected by `loading`. |
[data-loading] | loading | Sets aria-busy + aria-disabled and blocks activation, but stays FOCUSABLE — never native `disabled`, which would drop focus to <body> mid-flight. |
[data-state="success"] | success | Resolution glyph (check) draws in and completion is announced politely. |
[data-state="error"] | error | Resolution glyph (cross) draws in and failure is announced assertively. |
[data-full-width] | full-width | Present only when `fullWidth` is set (omitted, not `false`, at hug width). |
| Property | Token | Tier |
|---|---|---|
height | --garn-control-h-md | semantic · air |
paddingInline | --garn-control-px-md | semantic · air |
iconInset | --garn-control-icon-inset | semantic · identity |
ring | --garn-ringbrandable | semantic |
Private instance vars (never externalize): --btn-fill--btn-px--btn-ps--btn-pe
An in-place action: submit, save, confirm, delete, or open a dialog/menu.
Reach for something else when
<Button asChild> wrapping an anchor.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.
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.
leading/trailing so the optical padding trim applies.loading for work in flight — it keeps the button focusable and announced.state with onStateChange when you want the success/error glyph to clear itself.fullWidth with justify when a wide button should read as a row rather than a centered label.disabled to express pending — that drops focus to <body> and says nothing to AT. loading is the pending state.justify to do anything at hug width; it only bites once the button is wider than its content.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.
buttonAccessibility requirements
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)
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'
Clicking or pressing Enter/Space on a pending button does not fire onClick.
when loading || state === 'loading'
A plain disabled button keeps the native disabled attribute — loading is the only state that switches to aria-disabled.
when disabled && !loading
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'
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')
`description` is the SECONDARY line — give it a label to sit under.
when description != null && no text label in children
`description` needs a visible label to sit under; drop it or move to a text size.
when description != null && size starts with 'icon'
Keyboard
| EnterSpace | Activate the button. |
| EnterSpace | While 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. |
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.
asChildButton 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:
<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.
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.
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.