Stateful Button
Morphs from idle to loading to success, resizing smoothly between states.
components/ui/stateful-button.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Check, Loader2, Rocket } from "lucide-react";
import { useState } from "react";
type Status = "idle" | "loading" | "success";
type StatefulButtonProps = {
/** Async work to run on click. Reject to return to idle. */
onAction?: () => Promise<void>;
labels?: Record<Status, string>;
};
export function StatefulButton({
onAction = () => new Promise((r) => setTimeout(r, 1600)),
labels = { idle: "Deploy to production", loading: "Deploying", success: "Deployed" },
}: StatefulButtonProps) {
const [status, setStatus] = useState<Status>("idle");
async function run() {
if (status !== "idle") return;
setStatus("loading");
try {
await onAction();
setStatus("success");
setTimeout(() => setStatus("idle"), 2200);
} catch {
setStatus("idle");
}
}
return (
<motion.button
layout
onClick={run}
disabled={status === "loading"}
aria-live="polite"
transition={{ layout: { type: "spring", stiffness: 420, damping: 32 } }}
className={`inline-flex items-center overflow-hidden rounded-full px-6 py-3 text-sm font-medium text-white shadow-lg transition-colors duration-300 disabled:cursor-wait ${
status === "success" ? "bg-emerald-600 shadow-emerald-600/25" : "bg-zinc-900 shadow-zinc-900/20"
}`}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.2 }}
className="flex items-center gap-2 whitespace-nowrap"
>
{status === "idle" && <Rocket className="size-4" />}
{status === "loading" && <Loader2 className="size-4 animate-spin" />}
{status === "success" && <Check className="size-4" />}
{labels[status]}
</motion.span>
</AnimatePresence>
</motion.button>
);
}
export default function Demo() {
return <StatefulButton />;
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/stateful-button.tsxcomponents/ui/stateful-button.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Check, Loader2, Rocket } from "lucide-react"; import { useState } from "react"; type Status = "idle" | "loading" | "success"; type StatefulButtonProps = { /** Async work to run on click. Reject to return to idle. */ onAction?: () => Promise<void>; labels?: Record<Status, string>; }; export function StatefulButton({ onAction = () => new Promise((r) => setTimeout(r, 1600)), labels = { idle: "Deploy to production", loading: "Deploying", success: "Deployed" }, }: StatefulButtonProps) { const [status, setStatus] = useState<Status>("idle"); async function run() { if (status !== "idle") return; setStatus("loading"); try { await onAction(); setStatus("success"); setTimeout(() => setStatus("idle"), 2200); } catch { setStatus("idle"); } } return ( <motion.button layout onClick={run} disabled={status === "loading"} aria-live="polite" transition={{ layout: { type: "spring", stiffness: 420, damping: 32 } }} className={`inline-flex items-center overflow-hidden rounded-full px-6 py-3 text-sm font-medium text-white shadow-lg transition-colors duration-300 disabled:cursor-wait ${ status === "success" ? "bg-emerald-600 shadow-emerald-600/25" : "bg-zinc-900 shadow-zinc-900/20" }`} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={status} initial={{ opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -14 }} transition={{ duration: 0.2 }} className="flex items-center gap-2 whitespace-nowrap" > {status === "idle" && <Rocket className="size-4" />} {status === "loading" && <Loader2 className="size-4 animate-spin" />} {status === "success" && <Check className="size-4" />} {labels[status]} </motion.span> </AnimatePresence> </motion.button> ); } export default function Demo() { return <StatefulButton />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.