An async-aware button with an idle → pending → success/error state machine. Composes over Button, adding promise tracking, abort support, minimum-pending opt-in, auto-reset, layered crossfade transitions, and live screen-reader announcements.
Dependencies
Interaction Type
Click triggers the async onAction callback. State transitions crossfade with opacity + scale(0.95) + 1px blur — one morph moment. Focus is never moved. Width is stabilized by an invisible sizing layer. Pending state disables interaction and announces via live region. Success/error auto-reset to idle after a configurable delay.
Props
Options you can pass to customize this component.
stateControlled state. Overrides internal state machine.
defaultStateDefault state for uncontrolled mode. Defaults to "idle".
onActionAsync callback triggered on click. The component tracks the returned promise and transitions through pending → success/error automatically.
onStateChangeCalled whenever the internal state changes.
minPendingMsMinimum milliseconds to display pending state. Prevents flicker for fast operations. Defaults to 0 (no minimum).
resetDelayMsMilliseconds before resetting from success/error to idle. Defaults to 2000. Set to 0 to disable.
idleLabelContent shown in idle state.
pendingLabelContent shown in pending state.
successLabelContent shown in success state. Defaults to "Done".
errorLabelContent shown in error state. Defaults to "Failed".
renderStateFull render control. Overrides individual label/icon props. Receives the current state.
announcementsPer-state strings announced via aria-live region for screen readers.
variantVisual style inherited from Button. Defaults to "solid".
sizeButton size inherited from Button. Defaults to "md".
disabledDisables the button. Also disabled automatically while pending.
classNameAdditional CSS classes merged via cn().
Installation
How to use
import { ActionButton } from "@/components/ui/action-button"
export function Demo() {
return (
<ActionButton
onAction={async (signal) => {
const res = await fetch("/api/save", { signal, method: "POST" })
if (!res.ok) throw new Error("Save failed")
}}
idleLabel="Save"
pendingLabel="Saving…"
successLabel="Saved"
errorLabel="Retry"
minPendingMs={600}
resetDelayMs={2000}
announcements={{
pending: "Saving…",
success: "Saved successfully",
error: "Save failed, click to retry",
}}
/>
)
}Source Code
Click the code icon in the top-right corner to view the source code.
License & Usage
// No code loaded.Click to trigger async state machine