Stateful button

Free · MIT

Async button that morphs idle to spinner to drawn check or shaking error, with a spring width change between labels.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/stateful-button.json

The file lands in components/goodui/stateful-button.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/stateful-button works too.

Usage

import { Send } from "lucide-react"
import { StatefulButton } from "@/components/goodui/stateful-button"

export function SendInvoiceButton({ invoiceId }: { invoiceId: string }) {
  return (
    <StatefulButton
      loadingText="Sending…"
      successText="Invoice sent"
      errorText="Try again"
      onClick={async () => {
        const res = await fetch("/api/invoices/" + invoiceId + "/send", { method: "POST" })
        if (!res.ok) throw new Error("Send failed")
      }}
    >
      <Send />
      Send invoice
    </StatefulButton>
  )
}

When to use

Use it for
  • Any action that calls the server and should report the result in place: save settings, send invoice, charge card.
  • Form submits where a toast would be too far from the button ("Saved" right where the user clicked).
  • Retry flows: after an error the button shakes, shows "Try again" and is clickable again.
Reach for something else
  • Only a spinner, no success or error state: button with loading is simpler and keeps all variants and tones.
  • Destructive actions that need a deliberate gesture: use hold-to-confirm.
  • Results that need more than two words (validation messages, partial failures): show them in alert or toast-stack.
If you needUse
The handler returns a promiseonClick={async () => …} (states run by themselves)
State comes from your own data layer (React Query, server actions)state={…} and onStateChange
Keep "Done" on screenresetAfter={0}
Text while loadingloadingText="Sending…"
Secondary placementvariant="outline" or "secondary"

Examples

Controlled by your own mutation state

const [state, setState] = React.useState<StatefulButtonState>("idle")

<StatefulButton
  state={state}
  onStateChange={setState}
  onClick={() => {
    setState("loading")
    saveSettings().then(() => setState("success"), () => setState("error"))
  }}
>
  Save settings
</StatefulButton>

Keep the success state

<StatefulButton variant="outline" resetAfter={0} successText="Connected" onClick={connectCalendar}>
  Connect Google Calendar
</StatefulButton>

API reference

PropTypeDefault
onClick
Return a promise to drive the states: pending → loading, resolved → success, rejected → error.
(event: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>—
state
Control the state yourself instead.
"idle" | "loading" | "success" | "error"—
onStateChange
Called on every state change, including the automatic return to idle.
(state: StatefulButtonState) => void—
variant
"default" | "outline" | "secondary": idle look; success turns emerald and error destructive in every variant.
"outline" | "default" | "secondary""default"
size
"sm" | "md" | "lg": height, text size and padding.
"sm" | "md" | "lg""md"
loadingText
Shown next to the spinner while loading; empty by default (spinner only).
React.ReactNode—
successText
Shown next to the drawn check after success.
React.ReactNode—
errorText
Shown next to the drawn cross after a failure; clicking again retries.
React.ReactNode—
resetAfter
Milliseconds before success/error returns to idle; 0 keeps it.
number2200
labels
Text for built-in controls and screen readers; pass translations here.
Partial<StatefulButtonLabels>—
sound
Plays "tap" on click, then "success" or "error" with the result, when the visitor has sounds on (lib/goodui-sounds); a sound name replaces the tap. Off by default.
boolean | SoundName—

Also accepts every <button> attribute.

Accessibility

Enter / SpaceRuns onClick (native <button>). Ignored while loading or showing success.
TabMoves focus; a focus ring shows on keyboard focus only.
  • A polite live region announces "Working…", "Done" or "Something went wrong" (labels.loading / success / error) as the state changes.
  • The success (emerald-700) and error fills keep white text at 4.5:1 in light and dark.
  • While loading it sets aria-busy; while loading or showing success it sets aria-disabled and ignores clicks, but stays focusable.
  • The spinner, check and cross are aria-hidden SVGs; the visible text comes from children, loadingText, successText and errorText.

Motion

  • Labels swap with a vertical slide and a 3px blur on a spring (stiffness 500, damping 34); the button width springs to the new label (stiffness 420, damping 34).
  • Success draws a check in 0.3 s; error draws a cross stroke by stroke and shakes the button sideways over 0.42 s.
  • Success and error return to idle after `resetAfter` ms (2200), unless the state is controlled.
  • Reduced motion: labels cross-fade in 0.1 s, the width changes instantly, there is no shake or tap scale, and the check and cross appear already drawn. The spinner keeps turning, slower.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Make onClick return a promise (an async function) to get the automatic states; a handler that returns nothing leaves the button idle.
  • Throw or reject to show the error state; returning a value or resolving shows success.
  • When you pass state, the button is controlled: it never resets by itself, so set it back to "idle" yourself.
  • Built-in text comes from the labels prop: loading, success and error (screen reader messages) plus successText and errorText (default visible texts). The successText and errorText props still override the visible text.
  • Import the StatefulButtonState type from the same file when typing controlled state.

FAQ

How do I show an error message?

errorText shows a short label like "Try again". For a full message, catch the error in your handler, show it in an alert or toast, and rethrow so the button shows the error state.

Can I translate the screen reader announcements?

Not through props yet: the live region uses fixed English strings. The visible texts are all props.

Related