1"use client";2
3import React, { useEffect, useId } from "react";4import {5 AnimatePresence,6 motion,7 useReducedMotion,8} from "motion/react";9import { Check, Loader2, X } from "lucide-react";10import { cn } from "@/lib/utils";11
12export type MorphButtonStatus =13 | "idle"14 | "loading"15 | "success"16 | "error";17
18export type MorphButtonProps = {19 status?: MorphButtonStatus;20 onClick?: () => void;21 idleLabel?: string;22 loadingLabel?: string;23 successLabel?: string;24 errorLabel?: string;25 26 resetAfterMs?: number;27 onReset?: () => void;28 disabled?: boolean;29 className?: string;30 layoutId?: string;31 type?: "button" | "submit" | "reset";32};33
34const ICON_SPRING = { type: "spring" as const, bounce: 0, duration: 0.3 };35const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };36
37export function MorphButton({38 status = "idle",39 onClick,40 idleLabel = "Send invite",41 loadingLabel = "Sending",42 successLabel = "Sent",43 errorLabel = "Failed",44 resetAfterMs = 0,45 onReset,46 disabled,47 className,48 layoutId: layoutIdProp,49 type = "button",50}: MorphButtonProps) {51 const reduce = useReducedMotion();52 const reactId = useId();53 const layoutId = layoutIdProp ?? `morph-btn-${reactId}`;54
55 const textMap = {56 idle: idleLabel,57 loading: loadingLabel,58 success: successLabel,59 error: errorLabel,60 };61
62 useEffect(() => {63 if (64 resetAfterMs <= 0 ||65 (status !== "success" && status !== "error")66 ) {67 return;68 }69 const t = window.setTimeout(() => onReset?.(), resetAfterMs);70 return () => window.clearTimeout(t);71 }, [status, resetAfterMs, onReset]);72
73 const isBusy = status === "loading";74
75 return (76 <motion.button77 type={type}78 layout79 layoutId={layoutId}80 disabled={disabled || isBusy}81 onClick={onClick}82 transition={reduce ? { duration: 0.15 } : LAYOUT_SPRING}83 whileTap={84 disabled || isBusy || reduce ? undefined : { scale: 0.96 }85 }86 className={cn(87 "relative inline-flex h-10 min-w-30 items-center justify-center gap-2 overflow-hidden",88 "rounded-md px-4 text-sm font-medium",89 "outline-none select-none",90 "transition-[background-color,color,box-shadow] duration-(--dur-short) ease-out",91 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",92 "disabled:pointer-events-none disabled:opacity-60",93 status === "idle" &&94 "bg-(--color-ink) text-(--color-paper) shadow-[0_1px_2px_rgba(0,0,0,0.08)]",95 status === "loading" &&96 "bg-(--color-ink) text-(--color-paper)",97 status === "success" &&98 "bg-emerald-700 text-white dark:bg-emerald-600",99 status === "error" && "bg-red-700 text-white dark:bg-red-600",100 className,101 )}102 aria-live="polite"103 aria-busy={isBusy}104 >105 <AnimatePresence mode="wait" initial={false}>106 <motion.span107 key={status}108 className="inline-flex items-center gap-2"109 initial={110 reduce111 ? { opacity: 0 }112 : { opacity: 0, scale: 0.25, filter: "blur(4px)" }113 }114 animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}115 exit={116 reduce117 ? { opacity: 0 }118 : { opacity: 0, scale: 0.25, filter: "blur(4px)" }119 }120 transition={121 reduce ? { duration: 0.12 } : ICON_SPRING122 }123 >124 {status === "loading" && (125 <Loader2126 className="size-4 shrink-0 animate-spin stroke-[1.5]"127 aria-hidden128 />129 )}130 {status === "success" && (131 <Check className="size-4 shrink-0 stroke-[1.5]" aria-hidden />132 )}133 {status === "error" && (134 <X className="size-4 shrink-0 stroke-[1.5]" aria-hidden />135 )}136 <span className="whitespace-nowrap">{textMap[status]}</span>137 </motion.span>138 </AnimatePresence>139 </motion.button>140 );141}