All Taste components
Animated buttons
Loading button
A save button that swaps its label for a spinner, then a drawn check when the work is done.
idle · loading · success
Plays the whole sequence on a loop
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
type Phase = "idle" | "loading" | "done";
interface LoadingButtonProps {
onAction: () => Promise<unknown>;
label?: string;
loadingLabel?: string;
doneLabel?: string;
}
export function LoadingButton({ onAction, label = "Save changes", loadingLabel = "Saving…", doneLabel = "Saved" }: LoadingButtonProps) {
const [phase, setPhase] = useState<Phase>("idle");
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
async function run() {
if (phase !== "idle") return;
setPhase("loading");
try {
await onAction();
setPhase("done");
timer.current = setTimeout(() => setPhase("idle"), 1800);
} catch {
setPhase("idle");
}
}
const layer = "col-start-1 row-start-1 inline-flex items-center gap-1.5 transition duration-300 ease-taste motion-reduce:transition-none";
return (
<button
type="button"
onClick={run}
aria-busy={phase === "loading"}
className={cn(
"inline-grid h-11 min-w-40 place-items-center overflow-hidden rounded px-5 text-[15px] font-medium transition-colors duration-300 active:scale-[.97] motion-reduce:transition-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
phase === "done" ? "bg-success text-bg" : "bg-primary text-primary-fg hover:bg-primary-hover",
)}
>
<span aria-hidden={phase !== "idle"} className={cn(layer, phase !== "idle" && "-translate-y-2 opacity-0")}>{label}</span>
<span aria-hidden={phase !== "loading"} className={cn(layer, phase !== "loading" && "translate-y-2 opacity-0")}>
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
{loadingLabel}
</span>
<span aria-hidden={phase !== "done"} className={cn(layer, "ease-spring", phase !== "done" && "scale-75 opacity-0")}>
<Check className="size-4" />
{doneLabel}
</span>
<span role="status" className="sr-only">{phase === "loading" ? loadingLabel : phase === "done" ? doneLabel : ""}</span>
</button>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.