Designer 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.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
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.
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.
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.
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 |
Token usage per slot
root- spacing
- padding
spacing.compact - gap
spacing.tight
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.accent.bg - foreground
color.accent.fg - border
color.border.subtle - ring
color.border.focus
- background
- elevation
- shadow
elevation.sm
- shadow
- typography
- size
text.md - weight
weight.semibold
- size
icon-leading- spacing
- gap
spacing.compact
- gap
- color
- foreground
color.accent.fg
- foreground
label- color
- foreground
color.accent.fg
- foreground
- typography
- size
text.md - weight
weight.semibold - tracking
tracking.normal
- size
icon-trailing- spacing
- gap
spacing.compact
- gap
- color
- foreground
color.accent.fg
- foreground
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps the visual variant set (primary / secondary / tertiary / ghost / destructive). Figma stores it as a Variant property; code as a string union. |
Size | Enum | size | — |
Has Leading Icon | Boolean | iconLeading | Toggles 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 Icon | Boolean | iconTrailing | — |
Leading Icon | Slot | iconLeading | Swaps the icon component instance when 'Has Leading Icon' is true. Code passes the icon as slot child (React `leadingIcon` prop or named slot). |
Label | Text | children | Maps to the default slot / children — the label text. |
Loading | Boolean | loading | Drives 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 Width | Boolean | fullWidth | — |
Motion
| Transition | Duration token |
|---|---|
spinnerRotation | motion.duration.slow |
pressedFeedback | motion.duration.fast |
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.
Variants, properties, states
Variants
Structurally different versions of the component.
primary secondary tertiary ghost destructive Properties
The same component, parameterised.
| Property | Type |
|---|---|
size | sm | md | lg |
iconOnly | boolean |
fullWidth | boolean |
type | button | submit | reset |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | loadingpressed |
Figma↔Code mismatches
- 01 Figma
A button drawn as a styled `<div>` (or "rectangle with text") with click-through interactions in the prototype
CodeA real `<button>` element with native focus, native key handling (Enter / Space), and form participation
ConsequenceDesigners 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.
CorrectDocument 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>`.
- 02 Figma
Hover / focus / active / disabled modeled as Figma variants on every variant × size combination
CodeCSS pseudo-classes (`:hover`, `:focus-visible`, `:active`) and the HTML `disabled` attribute
ConsequenceVariant 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.
CorrectStates 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.
- 03 Figma
A "loading button" variant that visually replaces the label with a spinner
CodeA `loading` data state that disables the button, swaps the leading icon for a spinner, and announces busy via `aria-busy="true"`
ConsequenceDesigners 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.
CorrectDocument `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.
- 04 Figma
A "link button" variant that looks like a button but navigates
CodeA `<button>` cannot navigate; navigation requires `<a>`. The "link button" must render as an anchor with button styling.
ConsequenceDevelopers 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.
CorrectDistinguish 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.
Common mistakes
#button-icon-only-no-label
Icon-only button with no `aria-label`
The visual is a single icon and the button carries no accessible name. Screen readers announce "button" with no indication of what it does.
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`.
#button-disabled-tooltip
Disabled button has a tooltip explaining why
The `disabled` attribute removes the button from the focus order, so keyboard users cannot trigger the tooltip and never see the explanation.
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.
#button-loading-removes-label
Loading state replaces the label with a spinner only
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.
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.
#button-submit-default-in-toolbar
A button inside a `<form>` defaults to `type="submit"`
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.
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.
#button-text-in-anchor
A `<button>` wrapped in an `<a>` for navigation
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.
Use the right element: `<a>` for navigation styled as a button, `<button>` for in-page actions. Never nest interactive elements.
Used in patterns
- Confirmation Flowprimary action (destructive)
- Confirmation Flowcancel action
- Login Formprimary action (submit)
Accessibility hints
| 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. |