The kit's one clickable-action control. Plain buttons, icon-only actions, real navigation links, and split-buttons all render through this single component, so there is never a second button primitive to reach for.
When to reach for it#
Any element the user clicks to submit, navigate, or trigger a mutation. That includes icon-only row actions, the split-button pattern (a main action plus a caret-opened panel), and real navigation (href) where you want router link semantics (hover-prefetch, middle-click, copy-address) instead of a fake onClick-navigates button.
Do not reach for it when the surface needs to communicate state rather than trigger action. A record's status, a selection, or a schema field name are StatusPill, Chip, or FieldChip respectively (docs/design-language/03-status-language.md:8, "three small-tag species, never interchangeable"). Do not reach for it to build a per-row kebab menu either: the kit's own RowMenu (table/row-menu.tsx) does not compose on Button at all, it builds a raw <button className="btn btn-ghost btn-sm btn-square"> and drives Dropdown directly. If you need a trailing action menu, use RowMenu, not Button's dropdown prop.
Contract#
Identity and content:
content/children: the label.contentwins when both are given.presets: one or more named bundles (ButtonPreset) applying defaultIcon/content/color/variantbefore explicit props override them.new,edit,delete,save,download,back,refresh,copy,icon(empty content, for icon-only buttons),quiet(variant="text" shade, described in source as "the app's most-respelled combo, 28 of 29 text-variant uses").Icon/iconSize/iconProps/iconClassName(IconImportProps),iconRightto place it after the label.
Appearance:
color: the shared 8-waySemanticColor. Defaultneutral, the house primary CTA color (a dark-slate button in light mode, not literal grey).variant:solid | outline | ghost | link | text. Defaultsolid.size:xs | sm | md | lg | xl. Defaultmd.circle: icon-only circular sizing. Implemented and shipped, not a gap.dashed: dashed border, the "add another" affordance, pairs withvariant="outline".shade: on a quiet variant (notsolid), hover fills with a soft tint of the button's own color, the shared house hover vocabulary.fullWidth,noAnimate(entrance animation is on by default).badge/badgeCorner: a corner indicator rendered via daisyUI'sindicator-item.
Behavior:
onClick: sync or async.promisifymakes an asynconClickdrive the button's own pending state (spinner plus disabled) so callers don't need a separateuseState.loading: external loading flag, also disables the button (a spinner button must not accept a second click).IconOnClick: swaps the icon to a 1s checkmark acknowledgement after click (or a customIconRenderwhen given one instead oftrue).bricked: looks disabled but stays interactive, for pairing with atooltipthat explains why the action is blocked, rather than a silent disabled state.skeleton: shimmer placeholder in the button's own shape, content stays in the DOM for sizing but is color-masked.
Navigation and composition:
href/linkAs: renders a real anchor (or the given router link component) instead of a<button>.dropdown,badge, and the promisify/pending machinery are button-only and do not apply in link mode.dropdown/dropdownProps: split-button. A joined caret button opens a floating panel (built onDropdown) beside the main button.tooltip/tooltipProps: every Button is wrapped inTooltipunconditionally. Tooltip no-ops whencontentis empty, so this is always safe to pass.- Slot classnames per
ButtonClassNames:classNametargets the<button>,_classNamethe outer wrapper (the Tooltip's trigger div), plusiconClassNameandbadgeClassName.
An icon-only button (empty label) still needs an accessible name. The component derives one automatically in this order: an explicit aria-label, then the string tooltip, then the icon key spaced into words ("LeftArrow" becomes "Left Arrow"). Passing presets="icon" alone with a plain Icon still yields a serviceable, if generic, name; pass aria-label or a string tooltip for anything a screen reader user needs to act on precisely.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
variant="text" shade color="primary" Icon="..." content="..." | The quiet colored-label action, the "admin house trio" | speedway/app/routes/workspaces/jobs/job.tsx:302-310 (Continue job), :335-343 (Rerun job), speedway/app/routes/admin/team.tsx:384-390 (Open usage console) | Canon doc 2 names this the toolbar creation/forward-action idiom; it is also how promisify-free async actions with a loading flag render inline in a form |
href linkAs={...} variant="text" shade color="primary" Icon="Plus" content="..." | A quiet navigation CTA styled identically to an action button | speedway/app/routes/account/accounts.tsx:531-541 ("New workspace") | Confirms href composes with the full quiet-trio skin, not just solid |
variant="ghost" shade circle Icon="..." | An icon-only circular ghost button | walmart-mvp/frontend/src/pages/Login.tsx:59-63, walmart-mvp/frontend/src/pages/ImportParts.tsx:352-357 | The shipped shape for a dismiss/remove icon action next to text content |
variant="ghost" size="sm" presets="icon" color={conditional} Icon={conditional} | A permission-row icon action whose color and icon flip with record state | speedway/app/routes/admin/team.tsx:238-276 (Edit/Lock/Enable-Disable/Trash) | Canon doc 2 §C: identical pixel shape between staff and customer surfaces, different permission gates |
variant="ghost" Icon="Notification" badge={count} | A notification bell with an unread-count indicator | speedway/app/root.tsx:386-392 | The one shipped use of badge; the only prop combination that renders the corner indicator |
fullWidth on every CTA in a route | Full-width stacked auth buttons | speedway/app/routes/auth/login.tsx, forgot.tsx, reset.$token.tsx | Canon doc 2 §A3: "fullWidth belongs to signed-out auth screens only" |
Icon="RightArrow" iconRight | Forward-motion CTA, arrow trails the label | speedway/app/routes/auth/login.tsx:99 (Sign in), walmart-mvp/frontend/src/pages/ConnectLaunch.tsx:48 | Canon doc 2 §A3: iconRight "signals forward motion" |
variant="text" Icon="Right" iconRight (navigation) or variant="text" shade plus a tone (action) as a card, section or page-title toolbar's right-hand button | The toolbar action that declares itself: an arrow for a link, a shade and a tone for an action | apps/playground/src/app/landing-sections.tsx (See all, View), the docs home's Start-with strip | Canon doc 2 §A10: the toolbar right button is never the kit's zero (bare outline plus neutral); doc 60 §6 |
| Hover on any variant | The shade wash deepens; nothing underlines | every kit Button (packages/ui/src/button/button.tsx:154-157) | Doc 60 §5: a Button never underlines, at rest or on hover; underline belongs to prose links in Markdown and, since 2026-08-17, to nav-away links rendered as plain anchors (a footer link, an external site), which underline on hover because the visual cue of leaving outranks the control type. A project that rules otherwise for its inline links does so everywhere, never on one surface |
Banned and cautioned combinations#
The first entry and the last three are bans; the middle two are cautions. They share a section because a reader reaching for any of them should stop at the same page.
dropdown(split-button): implemented and typed, but has zero shipped call sites in either app. The one place that visually resembles a split-button,RowMenu, deliberately bypassesButtonand builds its own trigger andDropdownwiring. Before reaching forButton'sdropdownprop, confirmRowMenugenuinely does not fit; it is the proven pattern for a trailing action menu.promisifyandbrickedandIconOnClick: all implemented, all currently unused in shipped code. Cautioned, not banned: treat as unverified in production layouts. If you use one of these, screenshot-check it, since no existing screen has exercised it.- Raw daisyUI
btnclasses instead ofButton: cautioned. The canon (doc 2 §C) flags two known, bounded deviations: dense editable grids (contentgen setup, loadsheet alias rows) and the kit's ownModalFooter, both marked as a documented swap-later TODO, not a pattern to copy into new code. - A disabled button with no explanation: canon doc 2 §A4 is explicit that "a button that cannot act is hidden or explains itself, never silently disabled." Reach for
brickedplustooltip, or a plain-text hint beside the button, never baredisabled. - Bare
variant="outline" color="neutral"as a toolbar's right-hand button: banned across the app (owner, doc 59 G1; canon doc 2 §A10). That combination with no icon, noshadeand no tone is the kit's zero and reads as a placeholder next to a card title or a page title. Minimum isshade; an icon is strongly encouraged; a navigation action isvariant="text"withIcon="Right" iconRight; an action with a meaning takes its tone. And it lives in the toolbar slot, never in a row of its own under the title. - Underline on a Button: never, in any variant, at rest or on hover (doc 60 §5). Hover feedback is the shade wash. Underline is for prose links inside
Markdown.
What this saves you#
Three rules elsewhere in the canon stop being things you have to remember, if you use the hook or prop rather than hand-rolling it. All three sit in canon doc 2, buttons and actions, and the hooks are exported from the package root.
A disabled button that explains itself (02 §A4, AP-10 row 26). The Banned
combinations entry above tells you to reach for bricked plus tooltip. The
hook that produces exactly that pair is useDisabledReason
(hooks/use-disabled-reason.ts): give it a priority-ordered list of
[condition, reason] and spread its buttonProps. The first failing check
wins, so the ordering is the message, and a reason is a required part of every
entry. There is no way to use it and still ship a mute disabled button.
const guard = useDisabledReason([ [isLoading, "File details are loading"], [!template, "No template selected"],]);<Button content="Continue" {...guard.buttonProps} onClick={next} />An action that cannot double-fire. promisify routes onClick through
useAsyncAction (button.tsx:107), which ignores triggers while a run is in
flight, captures the error, and is safe across unmount. ConfirmModal uses the
same hook, so a confirm footer inherits the guard without opting in.
Note the boundary. This makes double-firing impossible; it does not report the
outcome. A confirm that starts async work still owes a toast, and that half is
yours (10 §A8, and see toast.md).
An icon-only button that still has a name (02 §A9). The Contract section
above states the derivation order: an explicit aria-label, then a string
tooltip, then the icon key spaced into words. That order is canon doc 2's
newest principle made structural. A Button with no visible label cannot ship
silently nameless, because the component supplies a name itself.
All three are classified in
docs/app-patterns/12-primitives-and-rules.md, which sorts every canon rule by
whether a primitive already retires it.
Before you adopt this#
Five questions to answer before reaching for Button.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Button has no shell-level twin to duplicate.
- Does this app already ship a local implementation of the same thing? Raw daisyUI
btnclasses still exist in two documented spots; check before copying them. - Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Button prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? An icon-only button derives its name from
aria-label, thentooltip, then the icon key. - Which canon §A rules bind this surface, and which does the composition break? §A4 bans a silently disabled button; use
brickedplustooltipor a hint instead.
Travels with#
Tooltip, unconditionally, since every Button wraps one. Passtooltipfreely; it costs nothing when empty.Dropdown, only through thedropdownprop or throughRowMenu, never composed by hand around aButton.RenderIcon/ the icon registry, neverreact-iconsdirectly at a callsite (canon doc 2 §A8).- Card's
toolbarslot: a card-scoped mutating action is aButtoninsidetoolbar, never a sibling div (canon doc 5 §A2).
Snippet#
<Button size="sm" variant="text" shade color="primary" Icon="Star" content="Open usage console" href={`/admin/teams/${org.id}/usage`}/>speedway/app/routes/admin/team.tsx:384-390