Bridge view

Button

An interactive element that triggers an action when activated. Anchors most user-initiated state changes — submitting forms, opening dialogs, advancing flows. Distinct from a Link (which navigates) in semantic and visual treatment.

When to use

Use

When the user needs to perform an action that triggers a state change, submits a form, or initiates a flow. Default activator for in-page mutations.

Avoid

For navigating to another page or external URL — that is `Link`, even when styled like a button. For binary on/off state — that is `Switch`. For opening a menu of multiple options — that is `MenuButton`.

Versus related

  • link

    `Link` navigates (changes the URL or opens a new tab); `Button` performs an action without navigation. Visual styling may converge but semantics never do — middle-click, "open in new tab", and copy-as-URL all rely on `<a>`.

  • menu-button

    `MenuButton` opens a menu with multiple options and carries `aria-haspopup="menu"` plus `aria-expanded`. `Button` triggers a single action.

  • icon

    `Icon` is the SVG-glyph primitive that may sit inside a Button as `icon-leading` or `icon-trailing`. Bare icons in clickable hosts are an anti-pattern — wrap in a `<button>` with an `aria-label` when the icon is the only label, so the host carries the activator semantics and the icon stays decorative.

  • switch

    A toggle `Button` with `aria-pressed` is a momentary action that may be in a pressed state (toolbar formatting — bold / italic / underline); `Switch` is a setting-state control whose activation persists as configuration. Decision test: does activation perform a transient action with state memory (toggle button) or change a configuration setting that persists (Switch)?

Button is the canonical primitive for in-page actions — a focusable, activatable element backed by HTML button semantics. The five variants (primary, secondary, tertiary, ghost, destructive) capture the emphasis ladder; properties cover size, icon-only, full-width, and the html type attribute. Loading and pressed states extend the data axis. The reference catalogues the keyboard contract, the loading-guard for in-flight async actions, the accessible-name rules for icon-only buttons, and where Button stops and Link begins.

Highlight
Fig 1.1 · Button · Bridge view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

headlessui Button
import { Button } from '@headlessui/react'
export default function Example() {
return (
<Button
type="button"
disabled={false}
className="rounded bg-sky-600 px-4 py-2 text-white data-[hover]:bg-sky-500 data-[disabled]:opacity-50"
onClick={() => console.log('clicked')}
>
Save changes
</Button>
)
}

Divergence

From Type → To Rationale
anatomy[icon-leading] omitted Headless UI Button is a thin wrapper around a native `<button>` element (DEFAULT_BUTTON_TAG = 'button' in button.tsx). It ships no named sub-slots. Icon-leading content is placed as plain React children; the library does not distinguish a leading icon position from the label or any other child. Source: https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/button/button.tsx
anatomy[label] reshaped Standard React children prop (no named slot) Canonical treats `label` as a named slot with explicit accessible-name semantics. Headless UI Button accepts `children` as a standard React children prop or a render function `(bag: ButtonRenderPropArg) => React.ReactNode`. There is no label slot distinction — all children are rendered directly inside the `<button>` element. Accessible-name derivation falls back to the native button text-content algorithm. Source: https://headlessui.com/react/button
anatomy[icon-trailing] omitted Same reason as icon-leading: Headless UI Button ships no named sub-slots. A trailing icon is placed as plain React children after the label text; the library does not track or expose a trailing-icon position concept. Source: https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/button/button.tsx
axes.variants[primary] omitted Headless UI is an unstyled accessibility primitive library. It ships no visual variant system; the `Button` component has no `variant` prop. All visual differentiation (primary, secondary, tertiary, ghost, destructive) is delegated to consumer CSS or utility classes via `className` or the data-attribute selectors the library exposes (`data-disabled`, `data-hover`, `data-active`, `data-focus`). Source: https://headlessui.com/react/button
axes.variants[secondary] omitted No visual variant system. See axes.variants[primary] rationale. Source: https://headlessui.com/react/button
axes.variants[tertiary] omitted No visual variant system. See axes.variants[primary] rationale. Source: https://headlessui.com/react/button
axes.variants[ghost] omitted No visual variant system. See axes.variants[primary] rationale. Source: https://headlessui.com/react/button
axes.variants[destructive] omitted No visual variant system. See axes.variants[primary] rationale. Source: https://headlessui.com/react/button
axes.properties[size] omitted No `size` prop. Headless UI is unstyled and ships no sizing scale. Padding, font-size, and min-height are consumer CSS. The Button component API in v2.1 exposes only `as`, `disabled`, `autoFocus`, and `type` as first-class props. Source: https://headlessui.com/react/button
axes.properties[iconOnly] omitted No `iconOnly` prop. Accessible-name provision for icon-only buttons (an `aria-label` on the root `<button>`) is the consumer's responsibility; the library provides no guard or helper for it. Source: https://headlessui.com/react/button
axes.properties[fullWidth] omitted No `fullWidth` prop. Block-level vs inline-level sizing is consumer CSS (`w-full` Tailwind class or equivalent). Source: https://headlessui.com/react/button
axes.states.data[loading] omitted Headless UI Button tracks no loading state. It does not manage `aria-busy`, spinner rendering, or click-guard-while-loading. Loading is a design-system convention that sits above the accessibility primitive; consumers implement it by setting `disabled` on the Button and managing their own `aria-busy` attribute via spread props or a wrapper component. Source: https://headlessui.com/react/button
axes.states.data[pressed] reshaped data-active render prop (transient press feedback only, no aria-pressed management) Canonical `pressed` is a persistent toggle-button data state that maps to `aria-pressed`. Headless UI Button exposes `active` (via `useActivePress` hook in button.tsx) as a transient pointer/keyboard- activation state — it is true while the user is pressing down and resets on release. This surfaces as `data-active` attribute and the `active` render-prop boolean. The library does not manage `aria-pressed`; persistent toggle-button semantics are consumer-wired. Source: https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/button/button.tsx
axes.states.interactive[focus-visible] renamed data-focus (via useFocusRing hook, exposed as focus render prop) Canonical uses the CSS-standard term `focus-visible` (aligned with `:focus-visible` pseudo-class). Headless UI exposes the state as `data-focus` attribute and a `focus` render-prop boolean, derived from the `useFocusRing` hook in button.tsx. The hook tracks keyboard-initiated focus (equivalent to `:focus-visible`) but the exposed name is `focus` / `data-focus`, not `focus-visible` / `data-focus-visible`. Consumers must use `data-[focus]:ring-2` (Tailwind) or `[data-focus]` (CSS) for ring styling. Source: https://headlessui.com/react/button
anatomy[root] extended + `as` prop (polymorphic rendering): accepts any HTML tag string or React component (default `"button"`). Allows rendering the Button as an `<a>` or a custom component while retaining Headless UI's interaction-state tracking (`data-hover`, `data-focus`, `data-active`, `data-disabled`, `data-autofocus`). The canonical root slot always renders as `<button>`; Headless UI's polymorphism is an extension beyond the canonical API. `autoFocus` prop: natively wires `autofocus` attribute and tracks the `data-autofocus` state for styling, beyond what the canonical axes expose. Headless UI's `as` prop is a library-wide design pattern allowing consumers to swap the underlying element for framework-router links or custom components without losing interaction-state data attributes. `autoFocus` surfaces the browser's native autofocus mechanic as a first-class tracked state rather than a bare HTML attribute passthrough. Source: https://headlessui.com/react/button
Why this audit reads the way it does

Headless UI Button (v2.1) is a minimal, unstyled accessibility primitive whose design philosophy diverges from the canonical Button in two fundamental ways: 1. No visual layer: Headless UI stops at the interaction-state machine (hover, focus, active, disabled). All visual concerns — variant (primary / secondary / ghost / destructive), size, icon slot positioning, full-width layout, and loading feedback — are delegated to consumer CSS. This is a deliberate trade-off: the library remains framework-agnostic for styling and composable with Tailwind CSS utility classes without imposing token opinions. 2. No sub-slot anatomy: The component is a single-element wrapper that renders one `<button>`. It exposes no named child slots for icon- leading, label, or icon-trailing. Icon placement and label markup are consumer-composed free children. This keeps the API surface minimal but shifts the accessible-name and icon-decoration contracts entirely to the consumer. The library does add genuine value above a bare `<button>`: the interaction-state data attributes (`data-hover`, `data-focus`, `data-active`, `data-disabled`) are computed correctly across pointer, keyboard, and touch inputs using React Aria's hook suite, and the `as` prop enables polymorphic rendering to router links while preserving those state attributes. These extensions are documented above as `extended` divergences on the root slot.

radix Button
import { Button } from '@radix-ui/themes';
{/* Basic usage */}
<Button variant="solid" size="2">Save changes</Button>
{/* With icon (free child — no named slot) */}
<Button variant="solid" size="2">
<PlusIcon />
Add item
</Button>
{/* Loading state */}
<Button loading>Saving…</Button>
{/* High-contrast ghost, full-width via className */}
<Button variant="ghost" highContrast className="w-full">Cancel</Button>
{/* asChild — render as anchor */}
<Button asChild variant="solid">
<a href="/dashboard">Go to dashboard</a>
</Button>

Divergence

From Type → To Rationale
anatomy[icon-leading] omitted Radix Themes Button has no `icon-leading` named slot or prop. Icons are passed as free React children alongside the label text; the component applies `gap` via CSS automatically (https://www.radix-ui.com/themes/docs/components/button — "You can nest icons directly inside the button. An appropriate gap is provided automatically"). There is no mechanism to distinguish a leading icon child from an arbitrary preceding child.
anatomy[icon-trailing] omitted Same rationale as icon-leading: Radix has no trailing-icon slot. Icons placed after the label text are trailing by position, not by a named slot or prop. The consumer controls order through JSX child ordering.
anatomy[label] omitted Radix Themes Button has no named label slot. The label is a free text node among the button's children. There is no `label` prop or named slot — children are rendered in a flex container with automatic gap between icon and text nodes. The canonical slot distinction (label vs icon-leading vs icon-trailing) exists only at the consumer layout level.
axes.variants[primary] omitted Radix Themes has no "primary" variant. The closest visual analogue is `variant="solid"` (filled background, highest visual weight), but Radix does not map the design-emphasis vocabulary (primary / secondary / tertiary / ghost / destructive) — it maps fill-style vocabulary (classic / solid / soft / surface / outline / ghost). There is no one-to-one equivalence; "primary" is a consumer-layer convention achieved by choosing a variant + color combination. (https://www.radix-ui.com/themes/docs/components/button)
axes.variants[secondary] omitted No "secondary" variant. Closest visual candidates are `variant="soft"` or `variant="surface"` depending on the design system's emphasis convention. Radix's style taxonomy does not encode hierarchy.
axes.variants[tertiary] omitted No "tertiary" variant. Closest analogue is `variant="outline"` or `variant="ghost"` at a lower hierarchy position, but again Radix does not express hierarchy — it expresses fill style.
axes.variants[destructive] reshaped color="red" (or another danger-signalling color) on any variant Radix has no "destructive" variant. Destructive intent is expressed via the `color` prop (e.g. `color="red"`) combined with any style variant. This is a two-prop composition rather than a single variant value. The canonical `destructive` variant bundles color + fill style into one enum value; Radix separates color from fill style orthogonally. (https://www.radix-ui.com/themes/docs/components/button)
axes.variants[ghost] renamed variant="ghost" One-to-one name match. Radix `ghost` renders with no background or border, matching the canonical ghost intent. Radix note: ghost buttons use a negative margin to optically align with surrounding text (https://www.radix-ui.com/themes/docs/components/button), which is an implementation detail not present in the canonical description.
axes.properties[size] reshaped size="1" | "2" | "3" | "4" (numeric, default "2") Canonical uses semantic size names (sm / md / lg). Radix Themes uses a numeric t-shirt scale 1–4 where "1" is smallest and "4" is largest. The number of tiers also differs: canonical has 3, Radix has 4. There is no mechanical mapping — a consumer bridging the two must decide whether sm → "1" or sm → "2" (the Radix default). (https://www.radix-ui.com/themes/docs/components/button)
axes.properties[iconOnly] reshaped IconButton (separate component — import { IconButton } from '@radix-ui/themes') Radix Themes does not support an `iconOnly` boolean on Button. Icon-only activators are a separate component `IconButton`, which ships its own padding and aspect-ratio treatment. A consumer wanting the canonical `iconOnly` variant must switch to a different Radix component entirely. (https://www.radix-ui.com/themes/docs/components/icon-button)
axes.properties[fullWidth] omitted Radix Themes Button has no `fullWidth` prop. Consumers achieve full-width rendering via className (`className="w-full"` with Tailwind) or inline style (`style={{ width: '100%' }}`). The canonical prop is a design-system convenience layer above what Radix ships.
axes.properties[type] reshaped HTML attribute passthrough via spread props Radix Themes Button does not declare `type` as an explicit prop, but since it renders a native `<button>` element and spreads all standard HTML attributes, `type="submit"` | `"reset"` | `"button"` can be passed as a plain HTML attribute. The canonical prop is first-class; in Radix it is an implicit passthrough with no type-system enforcement of the allowed values.
axes.states.data[loading] reshaped loading prop (boolean, default false) — replaces all button content with a spinner Canonical loading state preserves the label (visually or via visually-hidden text) and replaces only the leading icon with a spinner, announcing `aria-busy="true"`. Radix `loading` displays a spinner "in place of button content" — the label is not preserved in the DOM during loading, only the spinner. Radix also automatically applies `disabled` to prevent interaction. This diverges from the canonical prescription that the accessible name should remain stable and `aria-busy` should be used rather than `disabled`. (https://www.radix-ui.com/themes/docs/components/button)
axes.states.data[pressed] omitted Radix Themes Button has no `pressed` data state and does not manage `aria-pressed`. Toggle-button behaviour (e.g. toolbar formatting Bold / Italic / Underline) is not a first-class Radix Themes concern; consumers must add `aria-pressed` and its toggle logic themselves.
motion.durations omitted Radix Themes Button ships no motion durations. There is no enter/exit animation on the button itself; hover and active state changes are instantaneous CSS transitions defined in the Radix theme CSS with no consumer-configurable duration tokens.
motion.easing omitted Radix Themes Button applies no configurable easing. The internal CSS transitions use browser-default easing; there is no motion.easing token surface exposed to consumers.
events[click] reshaped native onClick (React.MouseEvent<HTMLButtonElement>) via HTML spread The canonical click event specifies a loading-guard (suppression when loading or disabled). Radix Themes enforces this by setting `disabled` on the element when `loading={true}`, which prevents the native click from firing at all. The canonical guard is therefore honoured through the disabled mechanism rather than through a handler guard. The event itself is a standard React synthetic MouseEvent, not a wrapped or renamed event.
axes.variants[ghost] extended + variant="classic" | "soft" | "surface" | "outline" — four additional fill-style variants (classic, soft, surface, outline) not present in the canonical variant set. These expose fill-style orthogonally from color and emphasis. Radix Themes separates fill style from emphasis, exposing 6 fill variants (classic, solid, soft, surface, outline, ghost) that can be combined with any `color` value. This is a deliberate product decision to let one component cover all fill styles rather than encoding emphasis in the variant name. The canonical emphasis ladder (primary / secondary / ghost) is a higher-level abstraction that a design system would layer on top of Radix's fill-style taxonomy. (https://www.radix-ui.com/themes/docs/components/button)
anatomy[root] extended + `color` prop (AccentColor enum — indigo, cyan, orange, crimson, red, gray, …), `highContrast` (boolean), `radius` ("none" | "small" | "medium" | "large" | "full"), `asChild` (boolean) Radix Themes Button exposes four additional props beyond the canonical surface. `color` selects the accent color from the theme palette independently of the variant — allowing e.g. a soft red button without a "destructive" variant. `highContrast` increases the color contrast between foreground and background for accessibility or emphasis. `radius` overrides the theme's corner radius per-instance. `asChild` (via Radix Slot) renders the button's behaviour onto a consumer-supplied child element (e.g. `<a>` for link-styled-as-button) without wrapping. None of these are present in the canonical surface. (https://www.radix-ui.com/themes/docs/components/button)
Why this audit reads the way it does

Radix Themes Button is a styled, opinionated component — unlike Radix Primitives (which has no Button at all). It ships a complete visual system (6 fill-style variants, 4 sizes, full color palette, radius control) but does not adopt the canonical emphasis-ladder vocabulary (primary / secondary / tertiary / ghost / destructive). The most substantive divergences are: 1. Variant taxonomy: Radix models fill style, not design emphasis. A design system using Radix Themes must map its own emphasis ladder onto Radix's (variant × color) matrix. 2. No named anatomy slots: icon-leading, icon-trailing, and label are all free children. The canonical slot contract (leading icon, label, trailing icon as distinct composition points) is a consumer responsibility. 3. Icon-only requires a different component (IconButton), not a prop. 4. Loading reshapes accessibility: Radix hides the label entirely and uses `disabled`, while the canon prescribes keeping the accessible name stable and using `aria-busy`. 5. No pressed / toggle-button state management. The `asChild`, `color`, `highContrast`, and `radius` props are genuine Radix extensions that give consumers compositional power beyond the canonical surface.

react-aria Button
import { Button } from 'react-aria-components';
// Basic usage
<Button onPress={() => alert('Pressed!')}>Save changes</Button>
// With isPending (loading state)
<Button isPending={isSaving} onPress={handleSave}>
{({ isPending }) => (
<>
{isPending && <ProgressCircle aria-label="Saving" />}
Save changes
</>
)}
</Button>
// With form integration (submit)
<Button type="submit">Submit</Button>
// Icon-only with aria-label
<Button aria-label="Close">
<CloseIcon />
</Button>
// isDisabled (maintains focusability, uses aria-disabled)
<Button isDisabled>Unavailable</Button>

Divergence

From Type → To Rationale
anatomy[icon-leading] reshaped children (render-props function or plain JSX) React Aria Button has no named `icon-leading` slot. Leading icons are passed as children alongside the label text, composed by the consumer inside the children prop (or a render-props function). There is no slot boundary enforced by the library — the canonical `icon-leading` slot concept is a consumer convention with no API surface in React Aria. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
anatomy[label] reshaped children (plain text or render-props) React Aria Button has no explicit `label` slot. The accessible name comes from children text or `aria-label`. When `isPending` is true the library dynamically updates `aria-labelledby` to combine the button's own label with the ProgressBar's `aria-label`, so the accessible name is computed, not a discrete slot. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx (fetched 2026-05-05)
anatomy[icon-trailing] reshaped children (render-props function or plain JSX) Same as icon-leading — no named trailing-icon slot. Trailing icons are composed inside children. This is consistent with React Aria's philosophy: the component manages behaviour and ARIA; layout/composition is the consumer's responsibility. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.variants[secondary] extended + React Aria's Tailwind/Vanilla CSS starter kits expose `variant="secondary"`, matching the canonical name. This is documented as a starter-kit convention, not a required prop on the unstyled primitive — the `variant` prop is consumer-defined. For styled starter kit consumers the mapping holds exactly. React Aria exposes `variant` as a pass-through for consumer styling rather than driving internal logic. The Vanilla CSS and Tailwind starter kits name the variants `primary | secondary | quiet | destructive` (Tailwind also `destructive`). The canonical `secondary` is present; `tertiary` and `ghost` are absent from the starter-kit set, replaced by `quiet`. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.variants[tertiary] omitted React Aria's documented starter-kit variants are `primary | secondary | quiet | destructive`. There is no `tertiary` variant. The canonical `tertiary` visual emphasis level is closest to `quiet` in the React Aria starter kit. Consumers layering their own design system can add tertiary as a custom variant value; the library places no constraint. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.variants[ghost] omitted Same as `tertiary` — no `ghost` variant in the React Aria starter kit. The `quiet` variant in the React Aria set is the closest analogue: reduced visual weight, no border or fill. Canonical `ghost` maps to `quiet` for consumers using the starter kit. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.properties[iconOnly] omitted React Aria Button has no `iconOnly` prop. The icon-only pattern is achieved by passing an icon as children and an `aria-label` on the button. The library enforces accessible-name requirements (via axe) but does not model icon-only as a variant or property. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.properties[fullWidth] omitted No `fullWidth` prop. Full-width layout is consumer CSS (`width: 100%` on the button or its container). React Aria Button does not alter layout. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.states.data[loading] renamed isPending React Aria uses `isPending` rather than `loading`. The semantics are close but not identical: `isPending` disables press/hover events while keeping the button focusable and in the accessibility tree, and it wires a ProgressBar component to `aria-labelledby` for screen-reader announcements. The canonical `loading` data state maps exactly to `isPending`; only the name differs. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx (fetched 2026-05-05)
axes.states.data[pressed] extended + React Aria exposes `isPressed` as a render-prop state (also surfaced as `[data-pressed]` data attribute) and separately provides `aria-pressed` as a passthrough. The canonical `pressed` data state is fully matched; React Aria additionally provides `onPressChange` for reactive updates and distinguishes `isPressed` (visual feedback) from `aria-pressed` (toggle semantics). The canonical pressed state documents `aria-pressed` usage for toggle buttons. React Aria goes further by providing a render-prop boolean (`isPressed`) that reflects the transient press state regardless of `aria-pressed`, enabling hover-like visual feedback during press without conflating it with toggle semantics. This is an additive extension. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx (fetched 2026-05-05)
axes.states.interactive[disabled] reshaped isDisabled prop (renders aria-disabled="true", not HTML disabled attribute) The canonical reference distinguishes `disabled` (removes from tab order) from `aria-disabled` (keeps focusable), noting `aria-disabled` is preferred when a tooltip explains the disabled state. React Aria resolves this ambiguity by always using `isDisabled` → `aria-disabled`, never the HTML `disabled` attribute. The button stays focusable and receives no press events when `isDisabled` is true. Consumers who *want* the button removed from the tab order must also set `excludeFromTabOrder`. This is a deliberate divergence from the canonical dual-mode model in favour of consistent accessibility. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
events[click] renamed onPress (PressEvent) React Aria replaces `onClick` with `onPress` for cross-device consistency. `PressEvent` carries pointer type (`mouse | touch | pen | keyboard`), position, keyboard modifiers, and a `continuePropagation()` method. Standard `onClick` is explicitly excluded from the props interface (GlobalDOMAttributes minus onClick). The canonical `click` event payload (`MouseEvent`) does not capture touch or pen devices; React Aria's `PressEvent` is a strict superset for pointer-type awareness. Additionally, `onPressStart`, `onPressEnd`, `onPressUp`, and `onPressChange` give lifecycle hooks the canonical event does not model. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
axes.properties[type] extended + React Aria Button supports all canonical `type` values (`button | submit | reset`) and additionally exposes `form`, `formAction`, `formMethod`, `formEncType`, `formTarget`, `formNoValidate` — matching all HTML button form-related attributes as first-class props. When `isPending` is true and `type="submit"`, the component automatically converts the button to `type="button"` to prevent implicit form submission while pending. The canonical reference documents `type` as the only form-related property on the Button primitive, leaving the rest as passthrough. React Aria promotes all HTML form-button attributes to typed props, reducing prop spreading and enabling type-safe form composition. The auto-conversion of submit→button during pending is a behaviour addition the canonical does not specify. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx (fetched 2026-05-05)
motion.durations reshaped data-pending / data-pressed / data-hovered / data-focus-visible CSS attribute selectors React Aria Button ships no motion duration tokens. All interactive-state transitions (press feedback, pending entry) are driven by CSS attribute selectors on the data attributes exposed by ButtonRenderProps. The canonical motion.durations.pressedFeedback maps to a `[data-pressed]` CSS transition at the consumer's duration token; the canonical motion.durations.spinnerRotation maps to the consumer's animation on the ProgressCircle inside children. The library exposes the state hooks; the timing is entirely consumer-side. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx (fetched 2026-05-05)
motion.reducedMotionFallback omitted React Aria Button does not implement reduced-motion fallback. Consumers apply `@media (prefers-reduced-motion: reduce)` in their CSS. The library's data-attribute state machine still updates; motion suppression is entirely the consumer's CSS responsibility. Source: https://react-aria.adobe.com/Button (fetched 2026-05-05)
Why this audit reads the way it does

React Aria Button is a behaviour-only primitive: it owns the accessibility contract (ARIA attributes, press event normalisation, focus management) while leaving visual composition, slot anatomy, variant styling, and motion timing entirely to the consumer. Most divergences are reshapings, not omissions — the canonical concepts are present but surfaced through a different API shape (render-props children instead of named slots, `isPending` instead of `loading`, `onPress` instead of `onClick`). The two deepest structural divergences from the canon are: 1. No named anatomy slots — React Aria Button renders a single <button> and treats all visual sub-structure (icons, label, spinner) as consumer-composed children. 2. `isDisabled` always maps to `aria-disabled` (never `disabled`) — a deliberate accessibility-first choice that collapses the canonical dual-mode model into a single, always-focusable disabled behaviour. Variant naming is a starter-kit concern, not a primitive constraint; the `variant` prop is consumer-driven and the library places no validation on its values.

Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    A button drawn as a styled `<div>` (or "rectangle with text") with click-through interactions in the prototype

    Code

    A real `<button>` element with native focus, native key handling (Enter / Space), and form participation

    Consequence

    Designers prototype clickable rectangles that work with mouse input but break for keyboard users when implemented literally. Developers shipping divs lose form submission and have to reimplement keyboard activation manually.

    Correct

    Document the button as a semantic element in the canonical reference. Designers may draw rectangles in Figma but the component instance is named "Button" and developers always render it as `<button>`.

  2. 02
    Figma

    Hover / focus / active / disabled modeled as Figma variants on every variant × size combination

    Code

    CSS pseudo-classes (`:hover`, `:focus-visible`, `:active`) and the HTML `disabled` attribute

    Consequence

    Variant explosion: 5 variants × 3 sizes × 4 states × 2 widths = 120 component variants. The Figma file becomes unmaintainable and developers cannot mechanically map a Figma "hover variant" to a CSS rule.

    Correct

    States live on a separate states sheet, documented once. Figma variants are reserved for variant (primary / secondary / …), size, and width (auto / full). State styling is documented as CSS rules referencing `:hover`, `:focus-visible`, etc.

  3. 03
    Figma

    A "loading button" variant that visually replaces the label with a spinner

    Code

    A `loading` data state that disables the button, swaps the leading icon for a spinner, and announces busy via `aria-busy="true"`

    Consequence

    Designers may not realise the button must remain announced as busy; developers may strip the disabled attribute to keep the button focusable, missing the announcement entirely.

    Correct

    Document `loading` as a data state. The button is `aria-busy`, not `disabled` (so its label remains announceable), and a spinner replaces the leading icon. The button is non-activatable while loading via a click-handler guard, not via the disabled attribute.

  4. 04
    Figma

    A "link button" variant that looks like a button but navigates

    Code

    A `<button>` cannot navigate; navigation requires `<a>`. The "link button" must render as an anchor with button styling.

    Consequence

    Developers ship a `<button>` with a click handler that calls `router.push`. The element loses middle-click "open in new tab", cannot be copied as a URL, and breaks user expectations for links.

    Correct

    Distinguish at the canonical level: a `Button` triggers an action, a `Link` navigates. A "link styled as a button" remains an `<a>` element with button visual treatment. The component reference may document a `Link` component that shares the button's visual variants.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

primary secondary tertiary ghost destructive

Properties

The same component, parameterised.

PropertyType
size sm | md | lg
iconOnly boolean
fullWidth boolean
type button | submit | reset

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
loadingpressed
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps the visual variant set (primary / secondary / tertiary / ghost / destructive). Figma stores it as a Variant property; code as a string union.
SizeEnumsize
Has Leading IconBooleaniconLeadingToggles slot visibility in Figma. Code does not have a matching boolean — the icon-leading slot is conditionally rendered based on whether a child is provided.
Has Trailing IconBooleaniconTrailing
Leading IconSloticonLeadingSwaps the icon component instance when 'Has Leading Icon' is true. Code passes the icon as slot child (React `leadingIcon` prop or named slot).
LabelTextchildrenMaps to the default slot / children — the label text.
LoadingBooleanloadingDrives the data state. In Figma it toggles the spinner overlay; in code it sets `aria-busy="true"` and replaces the leading icon with a spinner.
Full WidthBooleanfullWidth
Designer

Figma anatomy

Slot Figma type Hint
root frame Auto-layout horizontal frame; min-height per size token; padding from variant set
icon-leading from icon-leading-text instance Icon component instance; size bound to host's size token
label from icon-leading-text text Text style bound to a "label" component property; truncates with ellipsis when single-line variant overflows
icon-trailing from icon-leading-text instance Icon component instance; visibility bound to host's "has trailing icon" property
Dev

Code anatomy

Slot Code slot Semantic
root root button
icon-leading from icon-leading-text icon-leading presentational-or-img
label from icon-leading-text label text
icon-trailing from icon-leading-text icon-trailing presentational-or-img
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-button>` host element that internally renders a `<button>` and exposes named slots for `icon-leading`, default (label), and `icon-trailing` attributes (`variant="primary"`, `size="md"`, `loading`); reflected to host classes for CSS selectors
React a single `<Button>` component that accepts `leadingIcon`, `trailingIcon`, and children for the label props with class-variance-authority for variant / size / fullWidth; `data-loading` attribute for the loading state
Angular (signals) a `<ui-button>` component projecting the label via `<ng-content>`; `[uiIconLeading]` / `[uiIconTrailing]` directives or named projection slots input<'primary' | 'secondary' | 'tertiary' | 'ghost' | 'destructive'>(); input<'sm' | 'md' | 'lg'>(); host-bound `[disabled]` and `[attr.aria-busy]`
Vue a `<Button>` SFC with named slots for leading and trailing icons and a default slot for the label defineProps with literal-union types; `:loading` boolean prop drives `aria-busy`
Both

Events

  1. click
    Payload
    Standard `MouseEvent` (or synthetic equivalent) fired on activation by pointer click, Enter, or Space. Suppressed when the button is in the `disabled` or `loading` data state — implementations guard the handler at the activation boundary so consumers never observe a click in those states.
    Web Components
    Native `click` event on the host `<button>`; consumers listen with `addEventListener('click', …)`. The host prevents default activation while `loading` or `disabled`.
    React
    `onClick(event: React.MouseEvent<HTMLButtonElement>)`. Loading-guard implemented by checking `props.loading` before invoking the supplied handler.
    Angular Signals
    `output<MouseEvent>('click')` or the host-binding `(click)` directive; loading-guard via `[disabled]` binding tied to the loading signal.
    Vue
    Native `@click` listener on the `<button>` element; wrapper components re-emit after the loading-guard.
Both

Form integration

name attribute
`<button>` with a `name` attribute participates in form submission — the button's `[name]=[value]` is appended to the form's FormData when this button is the submitter. Buttons without `name` do not contribute to FormData. The canonical reference treats `name` as a passthrough to the underlying `<button>`.
FormData serialization
On submit, only the activating submit button's `[name]=[value]` pair is appended (not all buttons in the form). This makes Button useful for multi-action forms — `<button name="action" value="save">` and `<button name="action" value="discard">` distinguish via the submitted value of the same key.
form.reset()
`<button type="reset">` calls `form.reset()` on the parent form, restoring controls to their default values and clearing any `setCustomValidity()` state. Programmatic `form.reset()` triggers the same path. Reset does not submit and does not navigate.
HTML5 validation
`<button type="submit">` triggers HTML5 validation (`form.checkValidity()`); the first invalid field receives focus and the form's `:invalid` pseudo-class state propagates. The `formnovalidate` attribute on the submit button suppresses validation for that specific submit path — useful for "save draft" patterns where partial input is allowed.
Both

Internationalisation

RTL · mirroring

Leading and trailing icons swap visual position via logical `inline-start` / `inline-end` properties — what was on the left in LTR appears on the right in RTL. Directional icons (chevrons, arrows indicating progression) flip horizontally to preserve their semantic direction. Loading-spinner rotation does *not* mirror — circular motion is direction-neutral. The focus ring, padding, and typography render identically in both directions.

Text expansion

Labels can grow 30–50% longer in German, Russian, or Finnish. The canonical Button does not enforce a max-width — buttons grow with their label. Truncation is a last resort and requires a `title` attribute carrying the full label so SR users still hear it. Icon-only buttons are immune (the icon does not translate); the aria-label is the translation surface.

Both

Accessibility

Slot Accessibility hint
root Always render as `<button type="button">` (or `type="submit"` inside a form). Do not use `<div role="button">` — it loses the implicit form participation and key handling. Disabled state via the HTML `disabled` attribute (not `aria-disabled`) for buttons that should not receive focus; use `aria-disabled` only when the disabled button must remain focusable for assistive-tech announcement.
icon-leading `aria-hidden="true"` on the SVG when the icon is purely decorative (paired with a visible label). If the icon is the sole signal of the host's intent (icon-only host variant), the host carries an `aria-label` describing the action — the icon never announces itself.
label Plain text node; no special role. The label is the host's accessible name unless overridden by `aria-label` / `aria-labelledby`. Avoid visually-hidden modifiers — the visible text and the announced name should match.
icon-trailing `aria-hidden="true"` on the SVG. If the icon communicates state ("opens menu", "external link", "syncing"), pair it with a visually-hidden text node inside the host or absorb the meaning into the host's `aria-label` — the icon must never carry its own accessible name.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus enters the button when reachable in tab order. The visible focus ring matches `color.border.focus`; non-disabled buttons are always reachable, `disabled` buttons are skipped.
Enter or SpaceActivates the button (same handler path as a pointer click). For Space, activation fires on key release, not on key down — matches the native `<button>` behavior.

Screen-reader announcements

TriggerExpected
Focus enters the buttonThe button's accessible name followed by "button" (e.g. "Save changes, button"). Icon-only buttons announce the `aria-label` in place of the missing visible label.
`loading` data state set to trueScreen readers announce the busy state via `aria-busy="true"`. The accessible name remains the original label ("Save changes, busy, button") rather than being replaced with "loading".
Disabled state setNative `disabled` removes the button from focus order silently. `aria-disabled="true"` keeps it focusable; SR announces "<label>, dimmed, button" or equivalent (varies by SR vendor).

axe-core rules to assert

  • button-name
  • aria-allowed-role
  • color-contrast
  • focus-order-semantics

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/button/a11y-fixture.json

Both

Common mistakes

Blocker

#button-icon-only-no-label

Icon-only button with no `aria-label`

Problem

The visual is a single icon and the button carries no accessible name. Screen readers announce "button" with no indication of what it does.

Fix

Every icon-only button has an `aria-label` describing the action ("Close", "Sort ascending"). Lint rule: any button whose textContent is empty must have `aria-label` or `aria-labelledby`.

Major

#button-disabled-tooltip

Disabled button has a tooltip explaining why

Problem

The `disabled` attribute removes the button from the focus order, so keyboard users cannot trigger the tooltip and never see the explanation.

Fix

Use `aria-disabled="true"` instead of `disabled` when the button needs to remain focusable for tooltip discovery. Activation is suppressed via a click-handler guard. The tooltip appears on focus and hover.

Major

#button-loading-removes-label

Loading state replaces the label with a spinner only

Problem

The visible label is replaced by a spinner, but the button's accessible name remains the original label. Screen-reader users hear "Save" while the button is in fact unactivatable.

Fix

Keep the label visible (or visually hidden but accessible) during the loading state. Set `aria-busy="true"`. Announce the loading start and completion via a live region rather than mutating the button's accessible name.

Major

#button-submit-default-in-toolbar

A button inside a `<form>` defaults to `type="submit"`

Problem

A button placed inside a form without an explicit `type` attribute submits the form when clicked. The first button in a toolbar accidentally becomes the form's submit on Enter.

Fix

Always set `type="button"` on buttons that do not submit. Make the canonical reference's default `type` value `"button"`, and require an explicit `type="submit"` opt-in.

Major

#button-text-in-anchor

A `<button>` wrapped in an `<a>` for navigation

Problem

The DOM has a button inside an anchor (`<a><button>Go</button></a>`), producing nested interactive elements. Assistive tech announces ambiguously, and click events bubble through both.

Fix

Use the right element: `<a>` for navigation styled as a button, `<button>` for in-page actions. Never nest interactive elements.

Both

Used in patterns