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.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add stateful-button.enStateful Button is a React loading button component built with Motion and Tailwind CSS. When onClick returns a Promise it switches to loading, success, and error states on its own, which suits async actions such as saving or submitting a form.
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/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- A "Save changes" button on a settings page
- Submitting a contact or registration form
- Single API actions such as "Send verification email" or "Resync"
- Row actions like publish or archive in an admin list
- Not a good fit: irreversible actions such as payments or deletions that need a deliberate confirmation; pair them with Slide to Unlock or Hold to Confirm first
Installation
npx webberui@latest add https://webberui.com/r/stateful-button.en.jsonOr, 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>;Measured width mode
Pass morph="measure" for a different transition: the component measures the next state's content with a hidden probe span and a ResizeObserver (adding the button's padding and border, then rounding up), and animates width with a spring (bounce: 0, duration: 0.26); the label and icon swap vertically — the new content slides in 8px from below while the old content fades out 8px upward, over 0.18s. When the success and error labels differ a lot in length, the width change is smoother than the default layout animation, and the content is never scaled mid-morph.
<StatefulButton
morph="measure"
onClick={send}
successChildren="Verification email delivered"
errorChildren="Failed"
>
Send verification email
</StatefulButton>In measure mode the button width is set by the component as an inline style, so width classes such as w-full have no effect; children is rendered once more as a hidden copy for measuring, so avoid putting stateful elements or elements with an id in it. The default is morph="layout", which behaves exactly as before.
Examples
Show the error state for HTTP errors
fetch doesn't reject on 4xx or 5xx responses, so check res.ok and throw yourself to make the button switch to error.
async function saveProfile() {
const res = await fetch("/api/profile", { method: "PUT" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
<StatefulButton
onClick={saveProfile}
successChildren="Saved"
errorChildren="Save failed, try again"
>
Save changes
</StatefulButton>With a Next.js Server Action
Calling a Server Action returns a Promise, so returning it from onClick drives the state machine.
"use client";
import { StatefulButton } from "@/components/ui/stateful-button";
import { publishPost } from "./actions"; // a "use server" file
export function PublishButton({ postId }: { postId: string }) {
return (
<StatefulButton onClick={() => publishPost(postId)} successChildren="Published">
Publish post
</StatefulButton>
);
}Controlled: stay on success
In uncontrolled mode, success and error return to idle after 1.5 seconds. To keep the success state, for example so a verification email isn't sent twice, switch to controlled mode and combine it with disabled.
"use client";
import * as React from "react";
import { StatefulButton } from "@/components/ui/stateful-button";
type State = "idle" | "loading" | "success" | "error";
export function SendVerification() {
const [state, setState] = React.useState<State>("idle");
async function send() {
setState("loading");
try {
const res = await fetch("/api/verify-email", { method: "POST" });
setState(res.ok ? "success" : "error");
} catch {
setState("error");
}
}
return (
<StatefulButton
state={state}
onClick={send}
disabled={state === "loading" || state === "success"}
successChildren="Verification email sent"
errorChildren="Couldn't send, try again"
>
Send verification email
</StatefulButton>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
children | React.ReactNode | — | Content shown in idle / loading |
successChildren | React.ReactNode | children | Content for the success state |
errorChildren | React.ReactNode | children | Content for the error state |
disabled | boolean | false | Disables the button; when uncontrolled it is disabled automatically while loading |
morph | "layout" | "measure" | "layout" | Width morph style: layout keeps the layout animation with a scale crossfade; measure measures the width and springs width, swapping content vertically |
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 pressloading: the spinner keeps rotating, and when uncontrolled the button is locked to prevent double submissionsuccess: a green background, with the checkmark drawing itself in viapathLengtherror: 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 withlayout - With
morph="measure", a hidden probe span measures the width,widthanimates with a spring, and the content swaps vertically
Accessibility
aria-busymarks the busy state while loading- A built-in
aria-live="polite"status region announces "Processing…/Success/Error" 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; with
morph="measure"the width switches in 0s and the content only fades, without vertical movement
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so you can import it from a Server Component page as usual; onClick is a function and needs to be passed from a client component.
Why doesn't it submit my form?
The button defaults to type="button". To submit a form with it, pass type="submit" explicitly; other native button attributes are passed through as well.
Can I change how long the success state stays?
Uncontrolled mode always returns to idle after 1.5 seconds, and there is no prop for it. For a different duration, or to stay on a state, use controlled mode and switch state yourself.
Can it be double-clicked?
In uncontrolled mode the button disables itself while loading to prevent duplicate submissions. Controlled mode doesn't disable automatically, so set disabled based on state yourself.
Do screen readers hear the state change?
Yes. The button carries aria-busy while loading, and a built-in aria-live="polite" region announces the loading, success, and error states as they change.
Draggable Snap Button
A button you can drag that springs back and snaps: wherever you drag it, on release a spring snaps it to the nearest snap point — with multiple snap points, arrow-key switching, and a pure spring-back mode.
Shimmer Button
A shimmer button with light flowing around its edge, an inner highlight lining, and dimensional hover feedback.