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…

"use client"

import * as React from "react"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Entrance fade for the state layer; a React 19 hoisted style, deduplicated by href. */
const KEYFRAMES = `@keyframes sb-state-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}`

export type StatefulButtonStatus = "idle" | "loading" | "success" | "error"

const STATUSES: StatefulButtonStatus[] = ["idle", "loading", "success", "error"]

export interface StatefulButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stateful-button.json

Prompt

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 onAction promise and a status prop 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-only aria-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.

On This Page