Async action button — width-stable idle → pending → success|error → idle. Interior loading-button contract, CIDS tokens, no motion.
Anatomy
┌─────────────────────────────┐ │ [spinner|✓|!] Label │ ← faces stacked in one grid cell └─────────────────────────────┘ idle → pending → success|error → idle (resetAfter) width = max(face) — no layout jump
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
onAction | () => unknown | — | Sync throw / rejected promise → error; else success. |
children | string | — | Idle label (also the accessible name base). |
pendingLabel | string | children | In-flight label. |
successLabel | string | "Done" | Settled-ok label + check. |
errorLabel | string | "Try again" | Settled-fail label + alert — next action, not diagnosis. |
resetAfter | number | 1400 | ms to hold success/error before idle. |
disabled | boolean | false | Genuinely unavailable (native disabled). Busy ≠ disabled. |
onError | (error: unknown) => void | — | Caller reports the error; button only shows state. |
variant | ButtonVariant | "primary" | Same surface map as Button. |
size | ButtonSize | "md" | Shared height scale. |
className | string | — | cn-merged. |
Motion
- · Label faces:
transition-opacity150ms (stacked grid — zero width jump) - · Press:
active:scale-[0.96]when idle/settled (suppressed while pending) - · Spinner:
animate-spin(globalprefers-reduced-motioncollapses the loop) - · No
motionpackage — portable core
A11y
- · Native
<button type="button">;aria-labeltracks the visible face - ·
aria-busywhile pending;aria-disabledwhile pending (not native disabled) - · Polite live region announces success / error labels
- · Hit area follows Button size (md 36 · lg 44)