A pill button that cycles through loading, success, and error states, animating its width, icon, and label for each.
"use client";
import { cn } from "@/lib/utils";
import { Loader } from "lucide-react";
import {
AnimatePresence,
motion as m,
Transition,
useAnimate,
} from "motion/react";
import { HTMLAttributes, useEffect, useState } from "react";
type IconProps = HTMLAttributes<SVGElement>;
const Icons = {
check: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clipRule="evenodd"
/>
</svg>
),
alert: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clipRule="evenodd"
/>
</svg>
),
};
type Status = "Loading" | "Success" | "Error";
const sequences: Status[] = ["Loading", "Success", "Loading", "Error"];
const SEQUENCEDURATION = 2000;
const buttonTransition: Transition = {
type: "spring",
bounce: 0,
duration: 0.6,
};
const spanTransition: Transition = {
type: "spring",
bounce: 0,
duration: 0.3,
};
const DynamicStatusButton = () => {
const [status, setStatus] = useState<Status>("Loading");
const [scope, animate] = useAnimate();
useEffect(() => {
if (status === "Error") {
setTimeout(() => {
animate(
scope.current,
{ rotate: [0, 20, -20, 0, 20, -20, 0] },
{ duration: 0.2 }
);
}, 750);
}
}, [status]);
useEffect(() => {
let currentIndex = 0;
const interval = setInterval(() => {
setStatus(sequences[currentIndex]);
currentIndex = (currentIndex + 1) % sequences.length;
}, SEQUENCEDURATION);
return () => clearInterval(interval);
}, []);
return (
<div className="full center">
<m.button
className={cn(
status === "Loading" &&
"bg-blue-100 shadow-none hover:bg-blue-100 text-blue-500",
status === "Success" &&
"bg-green-100 shadow-none hover:bg-green-100 text-green-500",
status === "Error" &&
"bg-red-100 shadow-none text-destructive hover:bg-red-100",
"rounded-full overflow-hidden flex items-center gap-2 px-4 py-2"
)}
animate={{
width: status === "Loading" ? 220 : status === "Success" ? 180 : 210,
}}
transition={buttonTransition}
>
<AnimatePresence mode="sync">
<div className="center">
{status === "Loading" && <Loader className="size-6 animate-spin" />}
{status === "Success" && (
<Icons.check className="size-6 fill-green-500" />
)}
{status === "Error" && (
<m.span ref={scope}>
<Icons.alert className="size-6" />
</m.span>
)}
</div>
{status === "Loading" && (
<m.span
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="whitespace-pre"
transition={spanTransition}
>
Analyzing Transaction
</m.span>
)}
{status === "Success" && (
<m.span
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="whitespace-pre"
transition={spanTransition}
>
Transaction Safe
</m.span>
)}
{status === "Error" && (
<m.span
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="whitespace-pre"
transition={spanTransition}
>
Transaction Warning
</m.span>
)}
</AnimatePresence>
</m.button>
</div>
);
};
export default DynamicStatusButton;