Morph State Button
A free animated button for React and Next.js: a react button loading animation that morphs from label to spinner to a drawn check.
- Automated axe check
- Reduced-motion tested
- SSR build verified
- Motion
- 1 runtime package
Morph State Button is a free animated button for React and Next.js, built with Motion and styled with Tailwind, that plays a complete button loading animation across three controlled states. On idle to loading the label fades and blurs out over 0.15s while the pill's width springs down to a circle-ish spinner pill, and on loading to success a check draws itself in via SVG pathLength over 0.35s while the width springs out to fit the success label, which rises into place.
The component is fully controlled: you pass state as idle, loading or success and flip it around your own async work, so there is no fetch logic hidden inside. The width morph is transform-based FLIP through Motion layout animation, never width keyframing, and the spinner rotation is plain CSS. It renders a real button that is disabled with aria-disabled while loading, announces Loading and the success label through a visually hidden aria-live region, and under reduced motion swaps the morph for a 150ms opacity crossfade at a fixed width with the check fully drawn.
Install
Add the @te registry to your components.json once:
{
"registries": {
"@te": "https://templateempire.io/r/{name}.json"
}
}Then install the component:
npx shadcn@latest add @te/btn-morph-stateUsage
import { useState } from "react";
import { BtnMorphState } from "@/components/btn-morph-state";
type SaveState = "idle" | "loading" | "success";
export default function SaveButton() {
const [state, setState] = useState<SaveState>("idle");
const save = async () => {
setState("loading");
await fetch("/api/save", { method: "POST" });
setState("success");
setTimeout(() => setState("idle"), 2000);
};
return (
<BtnMorphState state={state} onClick={save} successLabel="Saved">
Save changes
</BtnMorphState>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| state | "idle" | "loading" | "success" | required | Controlled state driving the morph. |
| children | ReactNode | required | Idle label. |
| successLabel | string | "Done" | Success text, shown beside the drawn check and announced through the live region. |
| onClick | () => void | undefined | Fires only in the idle state; loading and success clicks are ignored. |
| className | string | undefined | Additional classes merged onto the button. |
Customisation
Morph State Button declares no tokens of its own. The pill paints with the inverted foreground and background semantic tokens, the spinner and check inherit the same pair, and the focus ring uses the ring token, so retheming those tokens rethemes every state together. Change the labels with the children and successLabel props and restyle the pill from the className prop:
<BtnMorphState
state={state}
onClick={save}
successLabel="Subscribed"
className="px-8 text-base"
>
Subscribe
</BtnMorphState>
/* retheme the control via the semantic tokens */
:root {
--foreground: oklch(0.97 0.01 280);
--background: oklch(0.16 0.02 280);
--ring: oklch(0.72 0.15 280);
}Frequently asked questions
- Is Morph State Button free for commercial use?
- Yes. Morph State Button is MIT licensed, so you can use it in personal and commercial projects without attribution.
- Is the loading animation accessible and does it hurt performance?
- No on both counts. The width morph runs as a transform-based Motion layout animation, the spinner rotation is plain CSS and the check draw animates SVG pathLength, so no layout property is ever keyframed and the button reserves a fixed height with no shift. It is a real button, disabled with aria-disabled while loading so repeat clicks are ignored, and a visually hidden polite live region announces Loading and the success label while the spinner and check stay aria-hidden. Under reduced motion the states crossfade in 150ms at a fixed width with no spinner rotation and the check already drawn.
- How do I wire it to my own async action?
- The component is fully controlled and contains no fetch logic. Keep the state in your own component, pass it through the state prop, and flip it around your work: set loading before you start the request, set success when it resolves, and return to idle after a short delay. The onClick prop only fires in the idle state, so a pending request can never be triggered twice from the button.