Agent docs

Button

The kit's one clickable-action control.

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. content wins when both are given.
  • presets: one or more named bundles (ButtonPreset) applying default Icon/content/color/variant before 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), iconRight to place it after the label.

Appearance:

  • color: the shared 8-way SemanticColor. Default neutral, the house primary CTA color (a dark-slate button in light mode, not literal grey).
  • variant: solid | outline | ghost | link | text. Default solid.
  • size: xs | sm | md | lg | xl. Default md.
  • circle: icon-only circular sizing. Implemented and shipped, not a gap.
  • dashed: dashed border, the "add another" affordance, pairs with variant="outline".
  • shade: on a quiet variant (not solid), 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's indicator-item.

Behavior:

  • onClick: sync or async. promisify makes an async onClick drive the button's own pending state (spinner plus disabled) so callers don't need a separate useState.
  • 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 custom IconRender when given one instead of true).
  • bricked: looks disabled but stays interactive, for pairing with a tooltip that 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 on Dropdown) beside the main button.
  • tooltip / tooltipProps: every Button is wrapped in Tooltip unconditionally. Tooltip no-ops when content is empty, so this is always safe to pass.
  • Slot classnames per ButtonClassNames: className targets the <button>, _className the outer wrapper (the Tooltip's trigger div), plus iconClassName and badgeClassName.

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#

CombinationProducesWhere usedWhy
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 buttonspeedway/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 buttonwalmart-mvp/frontend/src/pages/Login.tsx:59-63, walmart-mvp/frontend/src/pages/ImportParts.tsx:352-357The 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 statespeedway/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 indicatorspeedway/app/root.tsx:386-392The one shipped use of badge; the only prop combination that renders the corner indicator
fullWidth on every CTA in a routeFull-width stacked auth buttonsspeedway/app/routes/auth/login.tsx, forgot.tsx, reset.$token.tsxCanon doc 2 §A3: "fullWidth belongs to signed-out auth screens only"
Icon="RightArrow" iconRightForward-motion CTA, arrow trails the labelspeedway/app/routes/auth/login.tsx:99 (Sign in), walmart-mvp/frontend/src/pages/ConnectLaunch.tsx:48Canon 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 buttonThe toolbar action that declares itself: an arrow for a link, a shade and a tone for an actionapps/playground/src/app/landing-sections.tsx (See all, View), the docs home's Start-with stripCanon doc 2 §A10: the toolbar right button is never the kit's zero (bare outline plus neutral); doc 60 §6
Hover on any variantThe shade wash deepens; nothing underlinesevery 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 bypasses Button and builds its own trigger and Dropdown wiring. Before reaching for Button's dropdown prop, confirm RowMenu genuinely does not fit; it is the proven pattern for a trailing action menu.
  • promisify and bricked and IconOnClick: 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 btn classes instead of Button: cautioned. The canon (doc 2 §C) flags two known, bounded deviations: dense editable grids (contentgen setup, loadsheet alias rows) and the kit's own ModalFooter, 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 bricked plus tooltip, or a plain-text hint beside the button, never bare disabled.
  • 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, no shade and no tone is the kit's zero and reads as a placeholder next to a card title or a page title. Minimum is shade; an icon is strongly encouraged; a navigation action is variant="text" with Icon="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.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, Button has no shell-level twin to duplicate.
  2. Does this app already ship a local implementation of the same thing? Raw daisyUI btn classes still exist in two documented spots; check before copying them.
  3. 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.
  4. 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, then tooltip, then the icon key.
  5. Which canon §A rules bind this surface, and which does the composition break? §A4 bans a silently disabled button; use bricked plus tooltip or a hint instead.

Travels with#

  • Tooltip, unconditionally, since every Button wraps one. Pass tooltip freely; it costs nothing when empty.
  • Dropdown, only through the dropdown prop or through RowMenu, never composed by hand around a Button.
  • RenderIcon / the icon registry, never react-icons directly at a callsite (canon doc 2 §A8).
  • Card's toolbar slot: a card-scoped mutating action is a Button inside toolbar, 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

@versable-git/ui · reference, canon, and method, read in place