Stateful Button

Morphs from idle to loading to success, resizing smoothly between states.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/stateful-button.tsx

    components/ui/stateful-button.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { Check, Loader2, Rocket } from "lucide-react";
    import { useState } from "react";
    
    type Status = "idle" | "loading" | "success";
    
    type StatefulButtonProps = {
      /** Async work to run on click. Reject to return to idle. */
      onAction?: () => Promise<void>;
      labels?: Record<Status, string>;
    };
    
    export function StatefulButton({
      onAction = () => new Promise((r) => setTimeout(r, 1600)),
      labels = { idle: "Deploy to production", loading: "Deploying", success: "Deployed" },
    }: StatefulButtonProps) {
      const [status, setStatus] = useState<Status>("idle");
    
      async function run() {
        if (status !== "idle") return;
        setStatus("loading");
        try {
          await onAction();
          setStatus("success");
          setTimeout(() => setStatus("idle"), 2200);
        } catch {
          setStatus("idle");
        }
      }
    
      return (
        <motion.button
          layout
          onClick={run}
          disabled={status === "loading"}
          aria-live="polite"
          transition={{ layout: { type: "spring", stiffness: 420, damping: 32 } }}
          className={`inline-flex items-center overflow-hidden rounded-full px-6 py-3 text-sm font-medium text-white shadow-lg transition-colors duration-300 disabled:cursor-wait ${
            status === "success" ? "bg-emerald-600 shadow-emerald-600/25" : "bg-zinc-900 shadow-zinc-900/20"
          }`}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={status}
              initial={{ opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -14 }}
              transition={{ duration: 0.2 }}
              className="flex items-center gap-2 whitespace-nowrap"
            >
              {status === "idle" && <Rocket className="size-4" />}
              {status === "loading" && <Loader2 className="size-4 animate-spin" />}
              {status === "success" && <Check className="size-4" />}
              {labels[status]}
            </motion.span>
          </AnimatePresence>
        </motion.button>
      );
    }
    
    export default function Demo() {
      return <StatefulButton />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.