Buttons
Stateful Button
An action button with a built-in idle → loading → success/error state machine — stable width, live-region announcements, auto-reset.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stateful-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StatefulButton" component using
lucide-react icons (Loader2 / Check / X).
Contract
- forwardRef<HTMLButtonElement> extending React.ButtonHTMLAttributes; spread
remaining props on the root <button>, merge className via cn(), default
type="button" (overridable by the consumer's props).
- Two usage modes sharing one render path:
- Controlled: status?: "idle" | "loading" | "success" | "error" — when set,
the internal machine is disabled and the consumer (e.g. a mutation layer)
drives the state.
- Uncontrolled: onAction?: () => Promise<void> — the button self-manages:
click → "loading", resolve → "success", reject → "error", then back to
"idle" after resetDelay (default 2000ms).
- Copy props: children is the idle label; loadingText / successText / errorText
default to "Saving…" / "Done" / "Failed".
Behavior
- Rendered status = status prop ?? internal state (controlled wins).
- Click handler: call the consumer's onClick first, then run the machine only
when uncontrolled (status undefined, onAction present, not already loading).
Clicking again while success/error is showing clears the pending reset timer
and restarts a fresh action.
- Handle rejection via onAction().then(onSuccess, onError) so a rejecting
promise never surfaces as an unhandled rejection.
- Timer hygiene: keep the reset timeout in a ref; clear it on unmount, and
guard every async setState behind a mounted ref so a promise settling after
unmount touches nothing.
- While loading: disabled (also honor the consumer's disabled) + aria-busy.
Rendering & styling
- Width stability: stack all four state layers into the same CSS grid cell
(inline-grid on the button, col-start-1 row-start-1 + whitespace-nowrap on
each layer). Inactive layers are `invisible` — they keep their natural width,
so the button is always as wide as its widest state and never jumps.
- Each layer = optional icon + label: loading → Loader2 with animate-spin
(motion-reduce:animate-none), success → Check, error → X, idle → text only.
- The active layer fades in via a ~200ms opacity/translate keyframe shipped in
a React 19 hoisted <style href precedence> tag; motion-reduce:[animation:none].
- Accessibility: all visual layers are aria-hidden; a single sr-only
aria-live="polite" span holds the current label — it is both the button's
accessible name and the announcer for state changes.
- Semantic tokens only: bg-primary text-primary-foreground for idle / loading /
success; error swaps to destructive tokens (bg-destructive/10 text-destructive,
dark:bg-destructive/20) with transition-colors; focus-visible:ring-ring;
disabled:opacity-50. No hardcoded colors.
Customization levers
- Reset dwell: resetDelay (ms) — 1500–3000 reads well; large values suit flows
where the user should read the error before retrying.
- Copy: children / loadingText / successText / errorText accept any ReactNode,
so labels can carry their own inline elements if needed.
- Error treatment: soft destructive tint by default; for a solid alarm style
swap to bg-destructive with a contrasting foreground token from your theme.
- Success color: stays primary on purpose (calm confirmation); remap to a
chart/success token via the status class map if your theme has one.
- Entrance motion: the sb-state-in keyframe (duration / translate distance) is
the only motion knob; keep it under ~250ms so feedback feels immediate.
- Size / shape: override px/py/rounded via className — the grid stack imposes
no size of its own.Concepts
- Single-button state machine — the async lifecycle (idle → loading → success/error → idle) lives inside one component, so call sites pass a promise instead of orchestrating spinner, checkmark and reset timer themselves.
- Controlled / uncontrolled duality — the same render path serves both a quick
onActionpromise and astatusprop driven by a real mutation layer; controlled mode simply switches the internal machine off. - Width-stable layer stack — all state layers occupy one grid cell and invisible layers keep their natural width, so switching between "Save changes" and "Charging…" never reflows the layout around the button.
- Live-region feedback — visual layers are
aria-hidden; one sr-onlyaria-live="polite"span is both the accessible name and the announcer, so screen readers hear "Saving… Done" without double-reading. - Timer hygiene — the reset timeout sits in a ref, cleared on unmount, and promise callbacks check a mounted ref first; a slow request settling after navigation leaks nothing.
- Error as token remap — the error state is a class-map swap to destructive tokens, not a second component; success deliberately stays primary so only failure changes the button's color story.