WebberUI

Stateful Button

A state-machine button running idle → loading → success / error, switching automatically from an async onClick, with the content crossfading and the width morphing along with it.

Loading preview…
npx shadcn@latest add https://webberui.com/r/stateful-button.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<StatefulButton />

Installation

npx shadcn@latest add https://webberui.com/r/stateful-button.json

Or, once registries are configured in components.json, install it as @webberui/stateful-button.

Usage

import { StatefulButton } from "@/components/ui/stateful-button";

async function save() {
  await fetch("/api/save", { method: "POST" });
}

<StatefulButton onClick={save} successChildren="Saved" errorChildren="Failed">
  Save
</StatefulButton>;

As long as onClick returns a Promise, clicking moves the button into loading automatically; once the Promise resolves it shows success, once it rejects it shows error, and after 1.5 seconds it returns to idle on its own.

Controlled mode

Passing state switches it to controlled mode, which turns off the built-in automatic transitions and reset — the state is entirely driven from outside:

const [state, setState] = React.useState<"idle" | "loading" | "success" | "error">("idle");

<StatefulButton state={state} onClick={() => setState("loading")}>
  Submit
</StatefulButton>;

Props

PropTypeDefaultDescription
state"idle" | "loading" | "success" | "error"Controlled state; when provided, it is driven from outside and the built-in automatic transitions and reset are turned off
onClick(e) => void | Promise<unknown>Click callback; when uncontrolled, returning a Promise runs the state machine automatically
childrenReact.ReactNodeContent shown in idle / loading
successChildrenReact.ReactNodechildrenContent for the success state
errorChildrenReact.ReactNodechildrenContent for the error state
disabledbooleanfalseDisables the button; when uncontrolled it is disabled automatically while loading

All other native <button> attributes are forwarded (except onDrag / onDragStart / onDragEnd / onAnimationStart / onClick).

State machine

  • idle: the default state, clickable, with slight scale feedback on press
  • loading: the spinner keeps rotating, and when uncontrolled the button is locked to prevent double submission
  • success: a green background, with the checkmark drawing itself in via pathLength
  • error: a red background, with the cross drawn in stroke by stroke and the whole button shaking from side to side once
  • Content switches crossfade with AnimatePresence (popLayout), and the button width morphs along with layout

Accessibility

  • aria-busy marks the busy state while loading
  • A built-in aria-live="polite" status region announces "處理中/成功/錯誤" as the state changes
  • When the user has "reduce motion" enabled at the system level, the rotation, shake, and offsets are disabled and the checkmark / cross appear instantly, while the content switch is kept so the meaning is preserved

On this page