MorphStatusButton is the one async button you reach for everywhere. Hand it an onClick that returns a promise and it auto-transitions to a spinner while in flight, then settles into success or a delightfully blameful error state. The label and icon cross-fade in place, and an invisible widest-label sizer keeps the width rock steady so nothing around it jumps. You can also drive it with a controlled status prop.
Preview
MorphStatusButton
Click to run a fake async action
Variants
It fails sometimes
When the returned promise rejects, the button drops into its error state.
Doomed to fail
This one always rejects
Install
Add the item with the shadcn CLI.
npx shadcn@latest add @evilbuttons/morph-status-buttonUsage
import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button";
export function ButtonDemo() {
return (
<MorphStatusButton
successLabel="Saved"
onClick={async () => {
await saveChanges();
}}
>
Save changes
</MorphStatusButton>
);
}Or drive it from outside with a controlled status:
<MorphStatusButton status={isPending ? "loading" : "idle"}>
Save changes
</MorphStatusButton>Props
The component spreads any <button> HTML attributes except onClick (which is overloaded to accept an async handler).
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Idle label. Falls back to label. |
label | React.ReactNode | "Save changes" | Idle label used when no children are provided. |
loadingLabel | React.ReactNode | "Working…" | Label shown while the action is in flight. |
successLabel | React.ReactNode | "Done" | Label shown after the action resolves. |
errorLabel | React.ReactNode | "It broke. Your fault." | Label shown after the action rejects. |
onClick | (e) => void | Promise<unknown> | - | Click handler. Returning a promise triggers the auto state machine. |
status | "idle" | "loading" | "success" | "error" | - | Controlled status; disables promise-based transitions when set. |
resetAfter | number | 1800 | Milliseconds to stay in success/error before resetting. Set to 0 to stay. |
className | string | - | Extra classes passed to the button. |
Notes
- Built on the shadcn/ui
Button, so it acceptsvariantandsize(defaultsoutline/lg); the error state automatically switches to thedestructivevariant. - When
onClickreturns a promise (andstatusis not controlled), the button showsloading, thensuccesson resolve orerroron reject. - The button is disabled and
aria-busywhile loading so it cannot be double-submitted. - An invisible copy of the widest of all four labels reserves the width, so the icon/label swaps never shift surrounding layout.
- Icon and label cross-fades are skipped when the user prefers reduced motion, and
data-stateexposes the current state for styling.
Registry
The registry item includes components/evil-buttons/morph-status-button.tsx, installs clsx, tailwind-merge, and motion, and pulls in the standard shadcn/ui button registry item, which it composes as its base.