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

import { Button } from '@design-schema/react';

<Button label="Save changes" />

Props

15 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

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.

web, lit, rn, swiftui
variantprimary | secondary | ghost | dangerprimary

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`.

web, lit, rn, swiftui
sizesm | md | lgmd

Controls horizontal padding and font size. Touch targets never drop below the minimum regardless of size.

web, lit, rn, swiftui
leadingIconcontent—

Icon before the label. Decorative — hidden from assistive technology; the label carries the meaning.

web, lit, rn, swiftui
trailingIconcontent—

Icon after the label. Decorative, like leadingIcon.

web, lit, rn, swiftui
typebutton | submitbutton

`submit` submits the enclosing Form. Everything else is `button`.

web, lit, rn, swiftui
expandedboolean—

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.

web, lit, rn, swiftui
haspopupmenu | 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.

web, lit
disabledbooleanfalse

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.

web, lit, rn, swiftui
accessibleNamestring—

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.

web, lit, rn, swiftui
overflowLabelstring—

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.

web, lit, rn, swiftui
iconOnlybooleanfalse

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.

web, lit, rn, swiftui
loadingbooleanfalse

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`.

web, lit, rn
inversebooleanfalse

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.

web, lit, rn, swiftui
trackstring—

An event name sent to analytics when the button is pressed. Omit for no tracking.

web, lit, rn, swiftui

Events

2 rows
EventTypeDescriptionPlatforms
onPressonClick / 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.

web, lit, rn, swiftui
onTrackonTrack / 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 }`.

web, lit, rn, swiftui

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

containerlabelleadingIcontrailingIcon

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.

Contrast pairs

3 rows
ForegroundBackgroundLevel
color.action.{variant}.foregroundcolor.action.{variant}.backgroundAA
color.inverse.linkcolor.inverse.surfaceAA
color.inverse.focuscolor.inverse.surfaceAA (non-text)

This component does not animate, so it has no reduced-motion behaviour to describe.