Components / Interactive

Button

Button is the flagship interactive primitive — a labelled button with a three-axis style matrix, icon slots, and a loading state. It composes Pressable, so the accessibility contract comes for free.

Loading playground…
Updated 3 days agoEdit on GitHubWeb & native

What it is

A Pressable with a styled surface. Three props pick the look — variant (how heavy), intent (which colour), size (how large) — and the component resolves the matching token bag. Caller style props always override the matrix, so a one-off tweak does not need a new variant.

Beyond the matrix, Button adds composition slots (leadingIcon, trailingIcon) and a loading state that suppresses clicks and sets aria-busy.

Install

Button is exported from usemotif. No separate install.

example.tsx
import { Button } from 'usemotif';

API

Button

stable
function Button(props: ButtonProps): JSX.Element
variant"solid" | "outline" | "ghost"= "solid"

Visual weight. `solid` is filled, `outline` is bordered, `ghost` reads as a tap target on hover only.

intent"primary" | "danger" | "success" | "neutral"= "primary"

Semantic colour. Drives the `$colors.action.<intent>` token namespace.

size"xs" | "sm" | "md" | "lg" | "xl"= "md"

Padding, font size, and radius shorthand.

loadingboolean= false

Loading state. Suppresses clicks, sets `aria-busy`, and replaces `leadingIcon` with a loading indicator.

leadingIcon / trailingIconReactNode

Content rendered before / after the label.

loadingIcon / loadingLabelReactNode

Overrides for the loading indicator and the visible label while `loading` is true.

fullWidthboolean= false

Stretch to fill the parent's inline size.

…PressablePropsOmit<PressableProps, "children">

Every Pressable prop — `onPress`, `disabled`, style props, pseudo-state bags.

Variants

The look is a matrix of three axes — variant × intent × size. The axes are independent: any combination is valid, and defaultVariants mean <Button> with no props is a solid, primary, medium button.

  • variant sets the weight. solid fills the background; outline keeps the border and drops the fill; ghost drops both and reads as interactive only on hover.
  • intent sets the colour, pulling from $colors.action.<intent>. primary for the main action, danger for destructive ones, success for confirmations, neutral for secondary actions.
  • size sets padding, font size, and radius together — xs through xl.

Caller style props sit on top of the resolved matrix bag, so <Button intent="primary" bg="$colors.brand.500"> keeps the primary sizing and swaps only the background.

Accessibility

Button inherits Pressable's contract — it is a real <button>, focusable, keyboard-activated. On top of that:

  • The loading state sets aria-busy and suppresses the click, so a double-submit cannot slip through while a request is in flight. The visible label can switch to loadingLabel, but the element stays the same button — focus is not lost.
  • leadingIcon and trailingIcon are decorative. They sit inside an aria-hidden wrapper, since the label already carries the meaning. A button whose only content is an icon is the wrong tool — reach for IconButton, which requires an aria-label.

Examples

The default — solid, primary, medium:

example.tsx
<Button onPress={save}>Save changes</Button>

A destructive action:

example.tsx
<Button variant="outline" intent="danger" onPress={remove}>
Delete account
</Button>

With a leading icon and a loading state:

example.tsx
<Button
leadingIcon={<Icon><path d="M5 12h14" /></Icon>}
loading={pending}
loadingLabel="Saving…"
onPress={save}
>
Save
</Button>

A full-width button in a form:

example.tsx
<Button fullWidth size="lg" onPress={submit}>
Create account
</Button>