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.
defaultsoftoutlineghostControl height rung; `icon-*` sizes are square (icon-only).
xssmmdlgxlThe same control rendered across garn's density modes — compact, default, and spacious.
Interactive states, toggled live.
<Button>Button</Button>State
Color intent, applied to the default/soft/outline/ghost fills (`link` is tone-inert).
neutralbranddangersuccesswarningHow 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.
centerstartbetweenFills the inline axis of the container instead of hugging its content — releasing the base `shrink-0`. Usually paired with `justify`.
truefalseThe 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";Generated from the component's TypeScript 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"defaultasChildbooleandescriptionReact.ReactNodeleadingReact.ReactNodeloadingbooleanloadingTextReact.ReactNodeonStateChange(state: ButtonState) => voidstateButtonStatetrailingReact.ReactNodePlus 290 inherited native <button> attributes.
Target these data-slots 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"]| 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
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.buttonEnforceable contracts
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.
v0.3.0v0.1.0garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.