1"use client";2
3import React, { useEffect, useLayoutEffect, useRef, useState } from "react";4import {5 AnimatePresence,6 motion,7 useAnimate,8 useReducedMotion,9} from "motion/react";10import { cn } from "@/lib/utils";11
12export type MorphButtonStatus = "idle" | "loading" | "success" | "error";13
14export type MorphButtonProps = {15 status?: MorphButtonStatus;16 onClick?: () => void;17 idleLabel?: string;18 loadingLabel?: string;19 successLabel?: string;20 errorLabel?: string;21 22 idleIcon?: React.ReactNode;23 24 resetAfterMs?: number;25 onReset?: () => void;26 disabled?: boolean;27 className?: string;28 type?: "button" | "submit" | "reset";29};30
31const EASE_OUT = [0.23, 1, 0.32, 1] as const;32const WIDTH_SPRING = { type: "spring" as const, bounce: 0.18, duration: 0.5 };33const PAD_X = 18;34
35function Spinner() {36 return (37 <motion.svg38 viewBox="0 0 16 16"39 className="size-full"40 animate={{ rotate: 360 }}41 transition={{ repeat: Infinity, ease: "linear", duration: 0.8 }}42 >43 <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />44 <path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />45 </motion.svg>46 );47}48
49function DrawnGlyph({ kind, reduce }: { kind: "check" | "cross"; reduce: boolean }) {50 const draw = (delay = 0) => ({51 initial: { pathLength: reduce ? 1 : 0 },52 animate: { pathLength: 1 },53 transition: { duration: 0.32, ease: EASE_OUT, delay },54 });55 return (56 <svg viewBox="0 0 16 16" className="size-full" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">57 {kind === "check" ? (58 <motion.path d="M3.5 8.5l3 3 6-7" {...draw(0.06)} />59 ) : (60 <>61 <motion.path d="M4.5 4.5l7 7" {...draw(0.04)} />62 <motion.path d="M11.5 4.5l-7 7" {...draw(0.14)} />63 </>64 )}65 </svg>66 );67}68
69export function MorphButton({70 status = "idle",71 onClick,72 idleLabel = "Send invite",73 loadingLabel = "Sending",74 successLabel = "Sent",75 errorLabel = "Failed",76 idleIcon,77 resetAfterMs = 0,78 onReset,79 disabled,80 className,81 type = "button",82}: MorphButtonProps) {83 const reduce = !!useReducedMotion();84 const [scope, animate] = useAnimate<HTMLButtonElement>();85 const contentRef = useRef<HTMLSpanElement>(null);86 const [width, setWidth] = useState<number | null>(null);87 const [burst, setBurst] = useState(0);88 const prevStatus = useRef(status);89
90 91 92 const onResetRef = useRef(onReset);93 useEffect(() => {94 onResetRef.current = onReset;95 }, [onReset]);96
97 useEffect(() => {98 if (resetAfterMs <= 0 || (status !== "success" && status !== "error")) return;99 const t = window.setTimeout(() => onResetRef.current?.(), resetAfterMs);100 return () => window.clearTimeout(t);101 }, [status, resetAfterMs]);102
103 104 105 useLayoutEffect(() => {106 const el = contentRef.current;107 if (!el) return;108 const measure = () => setWidth(el.offsetWidth + PAD_X * 2);109 measure();110 const ro = new ResizeObserver(measure);111 ro.observe(el);112 return () => ro.disconnect();113 }, []);114
115 116 useEffect(() => {117 if (prevStatus.current === status) return;118 prevStatus.current = status;119 if (reduce) return;120 if (status === "success") setBurst((n) => n + 1);121 if (status === "error" && scope.current) {122 animate(scope.current, { x: [0, -7, 6, -4, 3, 0] }, { duration: 0.42, ease: "easeOut" });123 }124 }, [status, reduce, animate, scope]);125
126 const label = { idle: idleLabel, loading: loadingLabel, success: successLabel, error: errorLabel }[status];127 const icon =128 status === "loading" ? (129 <Spinner />130 ) : status === "success" ? (131 <DrawnGlyph kind="check" reduce={reduce} />132 ) : status === "error" ? (133 <DrawnGlyph kind="cross" reduce={reduce} />134 ) : (135 idleIcon ?? null136 );137 const isBusy = status === "loading";138
139 const swap = {140 initial: reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" },141 animate: { opacity: 1, y: 0, filter: "blur(0px)" },142 exit: reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(4px)" },143 transition: { duration: 0.28, ease: EASE_OUT },144 };145
146 return (147 <span className="relative inline-grid">148 <AnimatePresence>149 {burst > 0 && status === "success" ? (150 <motion.span151 key={burst}152 aria-hidden153 initial={{ opacity: 0.55, scale: 1 }}154 animate={{ opacity: 0, scale: 1.25 }}155 exit={{ opacity: 0 }}156 transition={{ duration: 0.7, ease: EASE_OUT }}157 className="pointer-events-none absolute inset-0 rounded-full bg-emerald-500/40"158 />159 ) : null}160 </AnimatePresence>161 <motion.button162 ref={scope}163 type={type}164 disabled={disabled || isBusy}165 onClick={onClick}166 aria-busy={isBusy}167 initial={false}168 animate={width ? { width } : undefined}169 transition={reduce ? { duration: 0 } : WIDTH_SPRING}170 whileTap={reduce || isBusy ? undefined : { scale: 0.97 }}171 className={cn(172 "relative inline-flex h-10 items-center justify-center overflow-hidden rounded-full",173 "text-[14px] font-medium whitespace-nowrap outline-none select-none",174 "shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12),0_6px_16px_-6px_rgba(0,0,0,0.3)]",175 "transition-[background-color,color,opacity] duration-300 ease-out",176 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",177 "disabled:cursor-default",178 disabled && "opacity-50",179 (status === "idle" || status === "loading") && "bg-(--color-ink) text-(--color-paper)",180 status === "success" && "bg-emerald-600 text-white",181 status === "error" && "bg-red-600 text-white",182 className,183 )}184 style={{ paddingInline: PAD_X }}185 >186 <span ref={contentRef} className="relative inline-flex w-max items-center gap-2">187 <AnimatePresence initial={false} mode="popLayout">188 {icon ? (189 <motion.span190 key={status}191 aria-hidden192 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(3px)" }}193 animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}194 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(3px)" }}195 transition={{ type: "spring", bounce: 0.3, duration: 0.4 }}196 className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-full"197 >198 {icon}199 </motion.span>200 ) : null}201 </AnimatePresence>202 <AnimatePresence initial={false} mode="popLayout">203 <motion.span key={label} {...swap} className="inline-block">204 {label}205 </motion.span>206 </AnimatePresence>207 </span>208 </motion.button>209 {}210 <span role="status" aria-live="polite" className="sr-only">211 {status === "idle" ? "" : label}212 </span>213 </span>214 );215}