Skip to content
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.