Stateful button
Free · MITAsync button that morphs idle to spinner to drawn check or shaking error, with a spring width change between labels.
Installation
$ npx shadcn@latest add https://goodui.dev/r/stateful-button.jsonThe 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
- 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.
- 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 need | Use |
|---|---|
| The handler returns a promise | onClick={async () => …} (states run by themselves) |
| State comes from your own data layer (React Query, server actions) | state={…} and onStateChange |
| Keep "Done" on screen | resetAfter={0} |
| Text while loading | loadingText="Sending…" |
| Secondary placement | variant="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
| Prop | Type | Default |
|---|---|---|
| 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. | number | 2200 |
| 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 / Space | Runs onClick (native <button>). Ignored while loading or showing success. |
| Tab | Moves 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
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
Press-and-hold button for destructive actions: a fill sweeps across with the label flipping color at its edge, early release rewinds it, works with Space and Enter.
Check or X in a circle that draws its stroke, fills, then pops with a ray-and-ring burst, for payment and booking result screens.
Self-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.