Status Button
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import { Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useState } from "react";
export function SaveButton() {
const [status, setStatus] = useState<"idle" | "loading" | "success">("idle");
const handleClick = () => {
setStatus("loading");
setTimeout(() => {
setStatus("success");
setTimeout(() => {
setStatus("idle");
}, 2000);
}, 2500);
};
const text = useMemo(() => {
switch (status) {
case "idle":
return "Save";
case "loading":
return "Saving";
case "success":
return "Saved";
}
}, [status]);
return (
<div className="relative inline-flex group font-sans">
<Button
onClick={handleClick}
className={cn(
"relative rounded-full h-12 px-8 text-base font-medium transition-all duration-300 min-w-[140px] disabled:opacity-100",
status === "idle"
? "transition-colors"
: "bg-muted text-muted-foreground hover:bg-muted cursor-not-allowed border-muted shadow-sm"
)}
variant={"default"}
disabled={status !== "idle"}
>
<span className="flex items-center justify-center">
<AnimatePresence mode="popLayout" initial={false}>
{text.split("").map((char, i) => (
<motion.span
key={`${char}-${i}`}
layout
initial={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
transition={{
type: "spring",
stiffness: 500,
damping: 30,
mass: 1,
}}
className="inline-block"
>
{char}
</motion.span>
))}
</AnimatePresence>
</span>
</Button>
{/* Status Indicator */}
<div className={cn("absolute -top-1 -right-1 z-10 pointer-events-none")}>
<AnimatePresence mode="wait">
{status !== "idle" && (
<motion.div
initial={{ opacity: 0, scale: 0, x: -8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, x: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0, x: -8, filter: "blur(4px)" }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className={cn(
"flex items-center justify-center size-6 rounded-full ring-3 overflow-visible",
status === "success"
? "bg-primary text-primary-foreground ring-muted"
: "bg-muted text-muted-foreground ring-muted "
)}
>
<AnimatePresence mode="popLayout">
{status === "loading" && (
<motion.div
key="loader"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8A8 8 0 0 1 12 20Z"
opacity=".5"
/>
<path
fill="currentColor"
d="M20 12h2A10 10 0 0 0 12 2V4A8 8 0 0 1 20 12Z"
// className="animate-spin"
>
<animateTransform
attributeName="transform"
dur="1s"
from="0 12 12"
repeatCount="indefinite"
to="360 12 12"
type="rotate"
/>
</path>
</svg>
</motion.div>
)}
{status === "success" && (
<motion.div
key="check"
initial={{ scale: 0, opacity: 0, filter: "blur(4px)" }}
animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
exit={{ scale: 0, opacity: 0, filter: "blur(4px)" }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="absolute inset-0 flex items-center justify-center"
>
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
Installation
npx shadcn@latest add "https://uselayouts.com/r/status-button.json"Install dependencies
npm install motion clsx tailwind-merge @hugeicons/react @hugeicons/core-free-iconsInstall UI components
Ensure you have the Button component from Shadcn UI installed.
npx shadcn@latest add buttonCopy the code
Copy the code from the Code tab above into components/save-button.tsx.
Usage
import { SaveButton } from "@/components/status-button";
export default function Page() {
return <SaveButton />;
}Features
- Multi-state: Handles Idle, Loading, and Success states in a single component.
- Text Morphing: Smooth character-level transitions for label changes.
- Visual Feedback: Icons and colors update dynamically to reflect current status.
- Interaction Safety: Disabled state during loading prevents duplicate submissions.
- Accessibility: ARIA-friendly state management.