Button
Triggers an action or event, such as submitting a form, opening a dialog, or confirming a choice.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Button } from '@design-schema/react'; Examples
Props
15 rows| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | The button's text. Also its accessible name. An empty string is allowed and warns on no platform, like Lit's initial `''` and an `iconOnly` button with no icon: it renders a nameless button, and nothing enforces WCAG 4.1.2 at runtime. Accessibility: Rendered as visible text, or as aria-label / accessibilityLabel when the button shows only an icon. |
| variant | primary | secondary | ghost | danger | primary | Visual emphasis. One primary button per view. These four are the whole set: there is no `outline` variant, and a bordered low-fill emphasis would be a new value here with its own colour pair and its own contrast proof, never an alias for `secondary`. |
| size | sm | md | lg | md | Controls horizontal padding and font size. Touch targets never drop below the minimum regardless of size. |
| leadingIcon | content | — | Icon before the label. Decorative — hidden from assistive technology; the label carries the meaning. |
| trailingIcon | content | — | Icon after the label. Decorative, like leadingIcon. |
| type | button | submit | button | `submit` submits the enclosing Form. Everything else is `button`. |
| expanded | boolean | — | Set by a parent that the button discloses (Menu, Popover, SidePanel, Disclosure): aria-expanded on web, accessibilityState.expanded on native. Consumers rarely set it directly. No default, and tri-state on every platform: undefined means the button discloses nothing, so no expanded state is reported at all (no aria-expanded on the container; `expanded` omitted from accessibilityState), never a false one. When the prop is set and an `aria-expanded` also arrives through `...rest`, the prop wins. |
| haspopup | menu | listbox | tree | grid | dialog | — | Set by a parent whose popup the button opens (Menu's trigger takes `menu`): `aria-haspopup` on the element that carries the button role, so on Lit it reaches the inner <button> the way `expanded` does, where a raw attribute on the host would not. No default: omitted means the button opens nothing and no aria-haspopup is written. The values are ARIA's own, without `true` (which means `menu`). When the prop is set and an `aria-haspopup` also arrives through `...rest` on web, the prop wins. Web and Lit only: native has no has-popup state, and the popup's own role carries the relationship there. |
| disabled | boolean | false | Prevents activation. The button stays in the tab order and is announced as disabled. A press blocked by `disabled` or `loading` is not a press: onPress does not fire and nothing chained from it (an extension's tracking) runs. Inside a disabled Form the button is disabled whatever this prop says: on web and rn Button reads `disabled` from the Form context and ORs it with this prop; on Lit ds-form sets `.disabled` on the ds-buttons it finds, so ds-button has no mechanism of its own. |
| accessibleName | string | — | Overrides the accessible name when it must say more than the visible label ("Sort by Amount, ascending" on a header that shows "Amount"). It wins over `label` everywhere, `iconOnly` included, where it replaces the label as the aria-label rather than being appended to it. The name must contain the visible label (WCAG 2.5.3 label-in-name); starting with it is preferred but not required, and no development warning checks it. Maps to aria-label / accessibilityLabel. |
| overflowLabel | string | — | Text used for this button when a Toolbar collapses it into its overflow Menu. Only Buttons collapse; other controls stay visible. Button itself never renders it: on web and React Native Toolbar reads it from the Button element's props (it never reaches the DOM or the Pressable); on Lit it is the plain `overflow-label` attribute on ds-button, which Toolbar reads from the host. |
| iconOnly | boolean | false | Hides the visible label and shows only `leadingIcon`; `trailingIcon` is not rendered either. `label` is still required and becomes the accessible name. Padding becomes equal on all sides: paddingInline takes the resolved paddingBlock (`space.sm` at its default), so a paddingBlock override keeps the sides equal and a paddingInline override has no effect while `iconOnly`. That also flattens the size ramp — an `iconOnly` sm and lg button differ in font and spinner size, not in padding — which is intended, since a square target should not stretch with its glyph. An `iconOnly` button with no `leadingIcon` is allowed and renders only its accessible name, with no development warning. The `label` part is not rendered at all while `iconOnly` — no visually hidden node either, since the name moves to aria-label — so this is the one place a prop removes an anatomy part, and a part selector or an `overrides` entry targeting `label` finds nothing. |
| loading | boolean | false | Shows a ring spinner (spinnerSize across, spinnerStroke thick, in the resolved foreground: `currentColor` on web/Lit) in the leading icon slot (whether or not `leadingIcon` is set; for `iconOnly` it replaces the sole glyph), hides `trailingIcon`, keeps the label visible and the button's height unchanged, and blocks repeat activation while an action is pending. With a `leadingIcon` the spinner swaps in at the same width, which holds exactly when that icon renders at the label's font size (the spinner is sized from `spinnerSize` alone, never from the icon it replaces); without one the spinner and iconGap widen the button, a shift that is accepted. `disabled` and `loading` may both be true: the button reports both states (aria-disabled and aria-busy) and the disabled dim applies while the spinner keeps turning. The spinner is not an anatomy part and carries no part name; it only takes the leadingIcon position, so no leadingIcon part is present while loading. `copy.loading` is announced as a description, never as part of the name, so it survives aria-label: web and Lit render it in a visually hidden, `aria-hidden` node inside the button's own tree referenced by aria-describedby (a description still resolves from a hidden node, and the virtual cursor does not read it a second time) (web merges it with any caller aria-describedby; on Lit only the internal id is referenced, since a caller's aria-describedby on the host cannot cross the shadow root); rn sets `accessibilityValue={{ text: copy.loading }}` beside `busy`, which react-native-web ignores (and aria-valuetext is not allowed on a button), so a react-native-web preview exposes only aria-busy and adds nothing else; SwiftUI uses `.accessibilityValue`. |
| inverse | boolean | false | The button sits on an inverse surface (Toast, Tooltip-like panels): `ghost` text uses color.inverse.link and hover uses inverseBackgroundHover at inverseHoverOpacity over the surface (the sanctioned color-mix of tokens on web/Lit; an alpha of the resolved color on native); the focus ring uses color.inverse.focus for every variant while `inverse` is true, since the ring must read against the inverse surface — `focusRingOffset` puts it clear of the button's own fill, so it is proven against color.inverse.surface and needs no pair against each variant's background. Only `ghost` changes its fill on inverse surfaces; other variants keep their own fills, which need no pair against color.inverse.surface: their text is proven against their own fill, and the label, not the fill edge, identifies the control. |
| track | string | — | An event name sent to analytics when the button is pressed. Omit for no tracking. |
Events
2 rows| Event | Type | Description |
|---|---|---|
| onPress | onClick / press / onPress / action | Fired when the button is activated by pointer, keyboard (Enter/Space), or assistive technology. No payload: pointer position and modifiers are not part of the contract, so the rn handler takes no arguments (the GestureResponderEvent is not passed). Web is the exception by name: `onClick` keeps React's native signature and receives the MouseEvent unchanged, so a composing parent (a Menu or Popover trigger) chains its handler as on any button; Lit `press` has no detail. |
| onTrack | onTrack / track | Fired after onPress with the `track` name and the button's label: two positional arguments on web, rn and swiftui; the Lit `track` event detail is `{ name, label }`. |
Accessibility
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
Required props that carry accessibility meaning: label. Each is described in the props table above.
Implicit ARIA role: button.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
This component does not animate, so it has no reduced-motion behaviour to describe.