# Button

> Start an action on the page: save, submit, open a dialog or confirm.

Source: https://www.youj.dev/docs/components/button

## Examples

### Async button

```tsx
'use client'

import { useState, type ReactNode } from 'react'
import { AsyncButton } from '@youj/react'
import { RefreshIcon } from '@youj/icons'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function AsyncButtonDemo() {
  const [synced, setSynced] = useState('2 minutes ago')

  return (
    <div className="w-full max-w-lg divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
      <Row title="Profile" detail="Name, avatar and time zone.">
        {/* Resolve for success. */}
        <AsyncButton onAction={() => wait(1200)} pendingLabel="Saving…" successLabel="Saved" errorLabel="Couldn’t save">
          Save
        </AsyncButton>
      </Row>
      <Row title="Payment method" detail="Visa ending in 4242. The last charge was declined.">
        {/* Reject, or throw, for an error. It shakes, then returns to rest. */}
        <AsyncButton
          variant="outline"
          onAction={async () => {
            await wait(1200)
            throw new Error('card_declined')
          }}
          pendingLabel="Charging…"
          successLabel="Paid"
          errorLabel="Card declined"
        >
          Retry charge
        </AsyncButton>
      </Row>
      <Row title="GitHub" detail={`Last synced ${synced}.`}>
        {/* Icon-only buttons stay square: their labels are announced, not shown. */}
        <AsyncButton
          variant="ghost"
          size="icon-sm"
          aria-label="Sync repositories"
          onAction={() => wait(900)}
          onActionStateChange={(state) => state === 'success' && setSynced('just now')}
          successLabel="Synced"
          errorLabel="Sync failed"
        >
          <RefreshIcon />
        </AsyncButton>
      </Row>
    </div>
  )
}

function Row({ title, detail, children }: { title: string; detail: string; children: ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-4 px-4 py-3">
      <div className="min-w-0">
        <p className="text-ui font-medium text-fg">{title}</p>
        <p className="text-ui-sm text-fg-muted">{detail}</p>
      </div>
      {children}
    </div>
  )
}
```

A settings list where each AsyncButton resolves, rejects or syncs through its own pending, success and error states.

When the attempt ends in an outcome people need to know, such as saving, sending, publishing or syncing, use `AsyncButton`. It is a Button (same `variant`, `size` and look) that morphs its label through the whole attempt: pending, then success or error. To show the same states on a control that isn't a button, use [Async Feedback](https://www.youj.dev/docs/components/async-feedback).

```bash
pnpm add @youj/react
```

For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation).

```tsx
import { AsyncButton } from '@youj/react'
```

Or copy the source into your app with the Youj CLI (import the package as above unless you copy in):

```bash
npx @youj/cli add async-button
```

It brings `async-feedback`, `button`, `morph-label`, `spinner`, `youj-core`, `youj-labels-button-async`, `youj-ui-core` with it.

The copy is 14 registry items, about 8,000 lines in all, 5,256 of them the shared `youj-core`. Beyond the shared ones it brings `async-feedback`, `button`, `icon-engine`, `icon-loader-ring`, `morph-label`, `spinner`.

```tsx
import { AsyncButton } from '@youj/react'

export function SaveBar() {
  return (
    <AsyncButton
      onAction={() => saveProfile(values)}
      pendingLabel="Saving…"
      successLabel={(profile) => `Saved ${profile.name}`}
      errorLabel={(error) => error instanceof Error ? error.message : 'Couldn’t save'}
    >
      Save
    </AsyncButton>
  )
}

// Work started elsewhere, such as a TanStack Query mutation: show its status.
<AsyncButton status={mutation.status} onClick={() => mutation.mutate(values)} successLabel="Saved" errorLabel={mutation.error?.message}>
  Save
</AsyncButton>
```

One driver per button: `onAction` runs the work itself, `status` shows work started elsewhere. The types allow only one.

##### Run an action

Pass `onAction` to show the action’s progress inside the button. The label changes to a spinner, then a check and `successLabel`, or a cross and `errorLabel`. It returns to idle after a hold. Resolve the promise for success; reject it or throw for an error. Presses are ignored while pending and during the success hold. After an error, the next press retries immediately.

The box morphs to each label's width on the same spring as the label, and success and error tint the button. Icon-only buttons stay square: their labels are announced, not shown. `onActionStateChange` reports every step (`idle`, `pending`, `success` or `error`, then `idle` again), and `resetAfter` sets the hold in milliseconds.

`onAction` is a function, so in the Next.js App Router the button goes in a Client Component, a file that starts with `'use client'`:

```tsx
'use client'

<AsyncButton
  onAction={() => saveProfile(values)}
  pendingLabel="Saving…"
  successLabel="Saved"
  errorLabel="Couldn’t save"
>
  Save
</AsyncButton>
```

##### Labels from the outcome

`successLabel` and `errorLabel` also take a function. `successLabel` gets what `onAction` resolved with, `errorLabel` what it rejected or threw with (typed `unknown`, even when your mutation knows its error type, so check it with `error instanceof Error`). Strings keep working.

```tsx
'use client'

// A fetch only rejects on a network failure: throw on !res.ok.
async function save(values: Profile) {
  const res = await fetch('/api/profile', { method: 'POST', body: JSON.stringify(values) })
  if (!res.ok) throw new Error((await res.json()).message)
  return res.json() as Promise<{ name: string }>
}

<AsyncButton
  onAction={() => save(values)}
  successLabel={(profile) => `Saved ${profile.name}`}
  errorLabel={(error) => (error instanceof Error ? error.message : 'Couldn’t save')}
>
  Save
</AsyncButton>
```

With TanStack Query, `mutateAsync` returns the promise and rejects on error, so it fits `onAction` as is:

```tsx
'use client'

const mutation = useMutation({ mutationFn: save })

<AsyncButton onAction={() => mutation.mutateAsync(values)} successLabel={(p) => `Saved ${p.name}`} errorLabel={(e) => (e instanceof Error ? e.message : 'Couldn’t save')}>
  Save
</AsyncButton>
```

##### Status from elsewhere

`status` drives the same pending, check or shake, hold, idle lifecycle for work started elsewhere. It takes `'idle' | 'pending' | 'success' | 'error'`, TanStack Query's mutation status names, so `status={mutation.status}` works as it is. Start the work in `onClick`.

```tsx
'use client'

const mutation = useMutation({ mutationFn: save })

<AsyncButton status={mutation.status} onClick={() => mutation.mutate(values)} successLabel="Saved" errorLabel={mutation.error?.message}>
  Save
</AsyncButton>
```

- A mutation keeps `success` until it is reset, so after the hold the button returns to rest even though `status` stays `success`. The next change of `status` plays again. A button that mounts already settled is at rest.
- `status` and `onAction` drive one lifecycle: pass one (the types allow only one, and with both, `status` wins and development builds log an error).
- Function labels need an outcome the button ran itself, so with `status` pass strings (`errorLabel={mutation.error?.message}`).
- Announcements, sounds and reduced motion behave as with `onAction`.

### Publish card

```tsx
'use client'

import { useState } from 'react'
import { Button, AsyncButton } from '@youj/react'
import { DownloadIcon, GitBranchIcon, MoreHorizontalIcon, SendIcon } from '@youj/icons'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function ButtonDemo() {
  const [published, setPublished] = useState(false)

  return (
    <div className="w-full max-w-md overflow-hidden rounded-xl border border-line bg-surface shadow-sm">
      <div className="flex items-start gap-3 px-5 pt-5 pb-4">
        <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-band text-fg">
          <GitBranchIcon size={16} />
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-ui-lg font-medium text-fg">Publish 3 changes to production</p>
          <p className="mt-1 text-ui text-fg-muted">
            {published
              ? 'Live on youj.io. The previous version is kept for rollback.'
              : 'Pricing page, new onboarding copy and the updated OG image.'}
          </p>
        </div>
        <Button variant="ghost" size="icon-sm" aria-label="More actions">
          <MoreHorizontalIcon />
        </Button>
      </div>
      <div className="flex items-center justify-end gap-2 border-t border-line bg-canvas px-5 py-3">
        <Button variant="ghost" onClick={() => setPublished(false)}>
          {published ? 'Roll back' : 'Discard'}
        </Button>
        <Button variant="outline">
          <DownloadIcon />
          Export
        </Button>
        {/* onAction runs the work and shows its whole lifecycle in place. */}
        <AsyncButton
          onAction={async () => {
            await wait(1600)
            setPublished(true)
          }}
          pendingLabel="Publishing…"
          successLabel="Published"
          errorLabel="Couldn’t publish"
          disabled={published}
        >
          {published ? 'Published' : 'Publish'}
          {!published && <SendIcon />}
        </AsyncButton>
      </div>
    </div>
  )
}
```

A publish card whose Publish button runs its own async action, beside Discard and Export.

### Variants

```tsx
'use client'

import type { ReactNode } from 'react'
import { Button } from '@youj/react'
import { ArrowUpRightIcon, DownloadIcon, PlusIcon, TrashIcon, UserPlusIcon } from '@youj/icons'

export default function ButtonVariants() {
  return (
    <div className="@container w-full max-w-xl">
      <div className="grid grid-cols-2 gap-x-6 gap-y-6 @md:grid-cols-3">
        <Variant name="default" note="The one primary action">
          <Button>
            <PlusIcon />
            New project
          </Button>
        </Variant>
        <Variant name="secondary" note="A second action beside it">
          <Button variant="secondary">
            <UserPlusIcon />
            Invite
          </Button>
        </Variant>
        <Variant name="outline" note="Neutral actions in toolbars">
          <Button variant="outline">
            <DownloadIcon />
            Export
          </Button>
        </Variant>
        <Variant name="ghost" note="Quiet actions and dismissals">
          <Button variant="ghost">Cancel</Button>
        </Variant>
        <Variant name="danger" note="Deletes or revokes something">
          <Button variant="danger">
            <TrashIcon />
            Delete
          </Button>
        </Variant>
        <Variant name="link" note="An action that reads as text">
          <Button variant="link">
            View logs
            <ArrowUpRightIcon />
          </Button>
        </Variant>
      </div>
    </div>
  )
}

function Variant({ name, note, children }: { name: string; note: string; children: ReactNode }) {
  return (
    <div className="flex flex-col items-start gap-2.5">
      {children}
      <div>
        <p className="font-mono text-ui-xs text-fg">{name}</p>
        <p className="text-ui-xs text-fg-muted">{note}</p>
      </div>
    </div>
  )
}
```

The six variants, each with a note on when to use it.

One `default` button per view carries the accent. `secondary` and `outline` sit beside it, `ghost` handles quiet actions and dismissals, and `danger` marks the action that deletes or revokes. `link` is an action styled as text. It's still a button.

Youj icons inside a button play when the button is hovered or keyboard-focused, with no wiring. An icon at either end sits 2px closer to its edge than text would, which reads as even padding.

### Sizes

```tsx
'use client'

import { Button } from '@youj/react'
import { PlusIcon, RocketIcon } from '@youj/icons'

const TEXT = [
  { size: 'xs', height: '24' },
  { size: 'sm', height: '28' },
  { size: 'default', height: '32' },
  { size: 'lg', height: '40' },
] as const

const ICON = [
  { size: 'icon-xs', height: '24' },
  { size: 'icon-sm', height: '28' },
  { size: 'icon', height: '32' },
  { size: 'icon-lg', height: '40' },
] as const

export default function ButtonSizes() {
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex flex-wrap items-end justify-center gap-x-5 gap-y-4">
        {TEXT.map(({ size, height }) => (
          <figure key={size} className="flex flex-col items-center gap-2">
            <Button size={size}>
              <RocketIcon />
              Deploy
            </Button>
            <figcaption className="font-mono text-ui-xs text-fg-muted">
              {size} · {height}px
            </figcaption>
          </figure>
        ))}
      </div>
      <div className="flex flex-wrap items-end justify-center gap-x-5 gap-y-4">
        {ICON.map(({ size, height }) => (
          <figure key={size} className="flex flex-col items-center gap-2">
            {/* Icon-only buttons are named by aria-label. */}
            <Button size={size} variant="outline" aria-label="New project">
              <PlusIcon />
            </Button>
            <figcaption className="font-mono text-ui-xs text-fg-muted">
              {size} · {height}px
            </figcaption>
          </figure>
        ))}
      </div>
    </div>
  )
}
```

Text sizes from extra small to large, and the four matching icon sizes.

Four text sizes and four square icon sizes sit on the control-height scale: 24, 28, 32 and 40px. On touch screens (`pointer: coarse`), every size is 8px taller and a default button is never under 44px, so it is a 44px target without a prop. The extra-small and small sizes stay compact and reach 44px with an invisible hit area around them.

An icon-only button has no visible text, so it needs an `aria-label`.

### Async

```tsx
'use client'

import { Button } from '@youj/react'
import { DownloadIcon, ListChecksIcon } from '@youj/icons'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function ButtonAsync() {
  return (
    <div className="flex w-full max-w-md flex-col gap-4 rounded-xl border border-line bg-surface p-4">
      <div>
        <p className="text-ui font-medium text-fg">Usage report</p>
        <p className="text-ui-sm text-fg-muted">September, all projects. 14,208 rows.</p>
      </div>
      <div className="flex flex-wrap gap-2">
        {/* onClick returns a promise: the button is busy until it settles. No state to keep. */}
        <Button variant="outline" onClick={() => wait(1800)}>
          <DownloadIcon />
          Export CSV
        </Button>
        <Button variant="secondary" onClick={() => wait(2400)}>
          <ListChecksIcon />
          Run checks
        </Button>
      </div>
    </div>
  )
}
```

Export CSV and Run checks buttons that stay busy while their onClick promise is pending.

Return a promise from `onClick` and the button looks after the wait: it shows its loading state (a spinner in place of the label, `aria-busy`, presses blocked, focus kept) until the promise settles, then returns to normal. There is no prop and no state to keep. A rejected promise ends the wait too, and the error surfaces as it would for any unawaited handler, so catch it in the handler if you need to show it. An explicit `loading` prop always wins.

```tsx
<Button onClick={() => exportReport()}>Export CSV</Button>
```

### Hold to confirm

```tsx
'use client'

import { AsyncButton } from '@youj/react'
import { TrashIcon } from '@youj/icons'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function ButtonHold() {
  return (
    <div className="flex w-full max-w-lg items-center justify-between gap-4 rounded-xl border border-danger-line bg-danger-subtle px-4 py-3">
      <div className="min-w-0">
        <p className="text-ui font-medium text-fg">Delete “atlas-web”</p>
        <p className="text-ui-sm text-fg-muted">Removes 214 deployments and every domain.</p>
      </div>
      {/* Runs only once the press is held: the fill sweeps across, letting go early springs it back. */}
      <AsyncButton variant="danger" hold onAction={() => wait(1200)} pendingLabel="Deleting…" successLabel="Deleted">
        <TrashIcon />
        Delete project
      </AsyncButton>
    </div>
  )
}
```

A danger zone whose Delete project button runs only after the press is held, then confirms as Deleted.

Hold to confirm lives on `AsyncButton`, not on the plain `Button`: pass `hold` and the action runs only once a press is held, by a pointer or with Space or Enter held down. While held, a fill sweeps across from the inline start, so the press shows how long is left. Letting go early springs the fill back and nothing runs. When the fill is full the action starts and the button goes through its usual lifecycle: the spinner, then the success tint with the drawn check and `successLabel`, held, then back to rest (or the error, if it fails). The press sound plays as the hold completes, not on the press. `hold` (or `hold={true}`) takes twice the `deliberate` motion token, scaled by the provider's speed; a number is milliseconds.

```tsx
<AsyncButton variant="danger" hold onAction={() => deleteProject(id)} pendingLabel="Deleting…" successLabel="Deleted">
  Delete project
</AsyncButton>
```

```tsx
'use client'

import { AsyncButton } from '@youj/react'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function AsyncButtonHold() {
  return (
    <div className="flex w-full max-w-md items-center justify-between gap-4 rounded-xl border border-line bg-surface px-4 py-3">
      <div className="min-w-0">
        <p className="text-ui font-medium text-fg">€1,240.00 to Northwind Ltd</p>
        <p className="text-ui-sm text-fg-muted">Invoice INV-2041 · arrives today</p>
      </div>
      {/* A full hold runs the action; then Sending… → Sent, as any AsyncButton. */}
      <AsyncButton hold onAction={() => wait(1400)} pendingLabel="Sending…" successLabel="Sent" errorLabel="Payment declined">
        Send payment
      </AsyncButton>
    </div>
  )
}
```

```tsx
<AsyncButton hold onAction={() => sendPayment(invoice)} pendingLabel="Sending…" successLabel="Sent">
  Send payment
</AsyncButton>
```

Use a hold for actions that are easy to trigger by mistake and costly to undo, where a dialog would be too much. For a slower, more deliberate gesture, use [Slide to Confirm](https://www.youj.dev/docs/components/slide-to-confirm).

### Loading

```tsx
'use client'

import { useState } from 'react'
import { Button } from '@youj/react'
import { DownloadIcon, ListChecksIcon } from '@youj/icons'

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export default function ButtonLoading() {
  const [exporting, setExporting] = useState(false)
  const [checking, setChecking] = useState(false)

  return (
    <div className="flex w-full max-w-md flex-col gap-4 rounded-xl border border-line bg-surface p-4">
      <div>
        <p className="text-ui font-medium text-fg">Usage report</p>
        <p className="text-ui-sm text-fg-muted">September, all projects. 14,208 rows.</p>
      </div>
      <div className="flex flex-wrap gap-2">
        {/* `loading` is controlled: you decide when the wait starts and ends. */}
        <Button
          variant="outline"
          loading={exporting}
          loadingLabel="Exporting report"
          onClick={async () => {
            setExporting(true)
            await wait(1800)
            setExporting(false)
          }}
        >
          <DownloadIcon />
          Export CSV
        </Button>
        <Button
          variant="secondary"
          loading={checking}
          loadingLabel="Running checks"
          onClick={async () => {
            setChecking(true)
            await wait(2400)
            setChecking(false)
          }}
        >
          <ListChecksIcon />
          Run checks
        </Button>
      </div>
    </div>
  )
}
```

Two buttons whose loading state is set from state you own.

`loading` is the controlled alternative, for work that starts somewhere else or outlives the press. The label hands over to a spinner without changing the button's width, presses are blocked, and focus stays on the button.

Pass `loading` (even as `false`) from the first render on any button that can load. Its live region then exists before it has anything to say, which some screen readers need before they'll announce a change.

## Installation & usage

```bash
pnpm add @youj/react
```

For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation).

Or copy the source into your app with the Youj CLI (import the package as above unless you copy in):

```bash
npx @youj/cli add button
```

It brings `spinner`, `youj-core`, `youj-labels-button`, `youj-presets`, `youj-ui-core` with it.

Use a button to save, submit, open a dialog or start another action on the page. For navigation, use a link; `buttonVariants` gives it the same look. For an option that stays on or off, use a [Toggle](https://www.youj.dev/docs/components/toggle) or a [Switch](https://www.youj.dev/docs/components/switch). For work that runs on press and shows its outcome in the button, see [Async actions](#async-actions).

```tsx
import { Button } from '@youj/react'

export function SaveBar() {
  return (
    <div className="flex justify-end gap-2">
      <Button variant="ghost">Cancel</Button>
      <Button type="submit">Save changes</Button>
    </div>
  )
}
```

It renders a native `<button>` through Base UI, which defaults to `type="button"`. Set `type="submit"` on the button that submits a form.

### As a link

Navigation should stay a link: it opens in a new tab, shows its URL and is announced as a link. Render the link through `render`, the same prop the triggers take, and it keeps the button's look and press.

```tsx
import Link from 'next/link'
import { Button } from '@youj/react'

<Button variant="outline" render={<Link href="/deployments" />}>
  View deployments
</Button>
```

A `render` element with an `href` or `to` (an anchor, Next.js `Link`, React Router `Link`) or another tag such as `span` sets `nativeButton={false}` for you, so Base UI doesn't expect a `<button>`. A link keeps its link role. A component without either prop can't be told apart from a button and stays native. If it renders something else, pass `nativeButton={false}` yourself.

In a server component, or anywhere you'd rather not render a Button, give the anchor the button's look with `buttonVariants`. It is a plain function that returns class names, with no React in it, so a server component can call it. The same goes for `badgeVariants`, `alertVariants`, `bannerVariants`, `buttonGroupVariants`, `toggleVariants` and `navigationMenuTriggerStyle`.

```tsx
// app/page.tsx: a server component, no "use client"
import Link from 'next/link'
import { buttonVariants } from '@youj/react'

export default function Page() {
  return (
    <Link href="/deployments" className={buttonVariants({ variant: 'outline' })}>
      View deployments
    </Link>
  )
}
```

## Description

Each example's description is under Examples.

## Code

Each example's code is under Examples.

## Reference

### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `'default' \| 'danger' \| 'ghost' \| 'outline' \| 'link' \| 'secondary'` |  | `default \| secondary \| outline \| ghost \| danger \| link`. |
| `loading` | `boolean` |  | Swaps the label for a spinner without changing the button's width, sets aria-busy, and blocks presses while keeping keyboard focus on the button. Pass it (even as `false`) on any button that can load, so its live region exists before the first announcement. Without it, a click handler that returns a promise puts the button in this state until the promise settles; an explicit `loading` (even `false`) wins. For a success or error label after the work, use AsyncButton. |
| `loadingLabel` | `string` | `"Loading"` | Announced when loading starts. |
| `sound` | `boolean` | `true` | With sound on (`YoujProvider`’s `sound`), the button plays `tap` as it's pressed. `false` keeps this one silent. |
| `size` | `'default' \| 'sm' \| 'lg' \| 'xs' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` |  |  |

Also takes the props of `ButtonPrimitive.Props`, `VariantProps<typeof buttonVariants>`.

`variant` and `size` both default to `default`. Base UI Button props pass through, such as `disabled` and `focusableWhenDisabled`.

#### Data attributes

The button carries `data-variant` and `data-size`, and `data-loading` while busy. An Async Button also carries `data-state`, and with `hold`, `data-yj-hold` (`idle`, `holding`, `release` or `done`); its fill reads `--ui--hold` (0 to 1).

#### Async Button props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `'default' \| 'danger' \| 'ghost' \| 'outline' \| 'link' \| 'secondary'` |  | `default \| secondary \| outline \| ghost \| danger \| link`. |
| `size` | `'default' \| 'sm' \| 'lg' \| 'xs' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` |  |  |
| `sound` | `boolean` | `true` | With sound on (`YoujProvider`’s `sound`), the button plays `tap` as it's pressed. `false` keeps this one silent. |
| `pendingLabel` | `string` |  | Shown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted. |
| `successLabel` | `string \| ((result: TResult) => string)` | `"Done"` | Shown beside the check and announced. A function gets what `onAction` resolved with: `successLabel={(user) => `Saved ${user.name}`}`. Under `status` there is no outcome to read, so pass a string. |
| `errorLabel` | `string \| ((error: unknown) => string)` | `"Failed"` | Shown beside the cross and announced assertively. A function gets what `onAction` rejected or threw with, whatever that is: `errorLabel={(error) => error instanceof Error ? error.message : 'Failed'}`. Under `status`, pass a string (`errorLabel={mutation.error?.message}`). |
| `resetAfter` | `number` |  | How long success / error hold before the label returns, in ms. Default: 3 × the deliberate token. |
| `onActionStateChange` | `(state: AsyncState) => void` |  | Fires on every state change: idle → pending → success \| error → idle. |
| `hold` | `boolean \| number` |  | Hold to confirm: the action runs only once a press is held (a pointer, or Space or Enter held down) for this long. `true` takes twice the deliberate motion token; a number is milliseconds. The button fills from its inline start while held and springs back if let go early; a full hold runs the action and the button carries on through pending to success or error. It is described as "Hold to confirm". |
| `status` | `'idle' \| 'pending' \| 'success' \| 'error'` |  | The same pending → success \| error → idle lifecycle for work started elsewhere, controlled: pass a mutation's status and the button shows it. The names are TanStack Query's, so `status={mutation.status}` works as it is; so does any state you map to them. - A `pending` status shows the spinner and blocks presses. `success` and   `error` draw their mark, hold for `resetAfter`, and return to rest even   while `status` stays put (a mutation keeps `success` until it's reset):   the next change of `status` plays again. A button that mounts already   settled is at rest. - Pressing runs `onClick`, as on any button: start the work there   (`onClick={() => mutation.mutate(input)}`). |
| `onAction` | `never` |  | Async work the button runs on press, with its whole lifecycle shown in place: the label morphs to a spinner (and `pendingLabel`), then to a drawn check and `successLabel`, or shakes to a cross and `errorLabel`, and returns to rest. Resolve for success; reject or throw for error. A second press while it runs (a double click) is ignored. |

It takes Button's `variant`, `size` and `sound`, and Base UI Button props such as `disabled`. It has no `loading`: the lifecycle is its own.

### Motion

| Moment | Tokens | Value | Note |
| --- | --- | --- | --- |
| Press | `duration.instant`, `ease.standard` | 80ms; cubic-bezier(0.2, 0, 0, 1) | scales to 0.97 while held |
| Release and hover | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | scale back, background and text colors |
| Loading | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | the label fades out under the spinner |
| Spinner | `duration.deliberate`, `ease.linear` | 560ms; cubic-bezier(0, 0, 1, 1); × 1.25 per turn | one turn at a constant rate |

**Reduced motion:** No press scale, and the spinner holds a still quarter arc.

#### Async Button motion

| Moment | Tokens | Value | Note |
| --- | --- | --- | --- |
| Label swap and tint | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | labels crossfade with a 0.9 scale and 2px blur |
| Width morph | `spring.snappy` | 0.2s, bounce 0 | the box follows the new label |
| Success check | `duration.slow`, `ease.emphasized` | 340ms; cubic-bezier(0.6, 0, 0.1, 1) | draws on, then lands |
| Check landing | `spring.settle` | 0.45s, bounce 0.3 | scale 0.88 to 1, after a `base` delay |
| Error shake | `duration.slow`, `ease.standard` | 340ms; cubic-bezier(0.2, 0, 0, 1) | 4, 3 and 2px, decaying |
| Hold | `duration.deliberate` | 560ms; × 3 | success or error before returning to rest, unless `resetAfter` is set |
| Hold-to-confirm fill | `duration.deliberate`, `ease.linear` | 560ms; cubic-bezier(0, 0, 1, 1); × 2 | with `hold`, sweeps from the inline start at a constant rate while held |
| Hold released early | `spring.smooth` | 0.36s, bounce 0 | the fill springs back to empty |
| Hold complete | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | the fill fades as the action starts |

**Reduced motion:** The pending label shows as text instead of a spinner, states swap instantly and errors don't shake.

#### Reduced motion

The press doesn't scale, and the spinner stops turning and holds a quarter arc. In an Async Button the pending state reads as its `pendingLabel` text, and every change after that is instant, with no shake. With `hold`, a percentage count stands in the label's place instead of the fill. Success still shows its check and label, and errors still read as errors.

### Accessibility

- It's a native `<button>`: `Enter` and `Space` activate it, and it takes part in form submission when `type="submit"`.
- Name icon-only buttons with `aria-label`. Youj icons inside are decorative (`aria-hidden`) unless you label them.
- An Async Button with `hold` is described as "Hold to confirm" (the `holdToConfirm` label), after any `aria-describedby` of your own. Space and Enter held down fill it from the keyboard; a key's own click, and a click from a press let go early, are ignored. Losing focus cancels a hold.
- `disabled` removes the button from the tab order. Keep a disabled button focusable with `focusableWhenDisabled` when people need to find it and read why it's unavailable.
- While `loading` or pending (including an async `onClick`), the button sets `aria-busy` and `aria-disabled`, blocks presses and keeps focus, so a keyboard user isn't dropped onto the page body mid-action. `loadingLabel` is announced politely.
- An Async Button is the same native `<button>`, ignoring presses while pending and during the success hold. Its accessible name is the current label, and the outgoing one is hidden from assistive technology while it fades. While pending, the name is `pendingLabel` ("Working…" by default), including on icon-only buttons, where it's visually hidden beside the spinner. Pending and success are announced politely, errors assertively, from live regions rendered beside the button, because a button flattens the content inside it.
