Status Button

status-button.tsx
"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-icons

Install UI components

Ensure you have the Button component from Shadcn UI installed.

npx shadcn@latest add button

Copy 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.