Solid button that morphs through idle, loading (spinner) and success (check) states with a spring width change and vertical label swap; driven by a timer or by an async onAction promise.
Preview
Customize
6 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add loading-buttonWrites components/ui/loading-button.tsx and components/styles/loading-button.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { LoadingButton } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <LoadingButton />6 );7}Props
Accessibility
Semantic <button type="button"> with aria-busy while loading and aria-live="polite" so state labels are announced; repeated clicks are ignored mid-cycle, pending timers are cleared on unmount, and prefers-reduced-motion swaps the slide/width spring for an instant crossfade and slows the spinner.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/loading-button.module.css";6 7type Phase = "idle" | "loading" | "success";8 9export interface LoadingButtonProps {10 /** Idle label (ignored when children are provided) */11 label?: string;12 /** Custom idle content */13 children?: React.ReactNode;14 /** Text shown next to the spinner while loading */15 loadingLabel?: string;16 /** Text shown next to the check mark on success */17 successLabel?: string;18 /** Simulated work duration in milliseconds (used when onAction is not provided) */19 duration?: number;20 /** How long the success state is held before returning to idle, in milliseconds */21 successHold?: number;22 /** Disable the button */23 disabled?: boolean;24 /** Click handler, called when a new cycle starts */25 onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;26 /** Async work that drives the loading state instead of the timer; rejection returns to idle */27 onAction?: () => Promise<unknown>;28 /** Optional className */29 className?: string;30}31 32function Spinner() {33 return (34 <svg className={styles.spinner} viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">35 <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />36 <path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />37 </svg>38 );39}40 41function CheckIcon() {42 return (43 <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">44 <path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />45 </svg>46 );47}48 49export function LoadingButton({50 label = "Deploy to production",51 children,52 loadingLabel = "Deploying",53 successLabel = "Deployed",54 duration = 1600,55 successHold = 1400,56 disabled = false,57 onClick,58 onAction,59 className = "",60}: LoadingButtonProps) {61 const prefersReducedMotion = useReducedMotion();62 const [phase, setPhase] = React.useState<Phase>("idle");63 const phaseRef = React.useRef<Phase>("idle");64 const timers = React.useRef<number[]>([]);65 const mounted = React.useRef(false);66 67 React.useEffect(() => {68 mounted.current = true;69 const pending = timers.current;70 return () => {71 mounted.current = false;72 pending.forEach((id) => window.clearTimeout(id));73 pending.length = 0;74 };75 }, []);76 77 const go = (next: Phase) => {78 if (!mounted.current) return;79 phaseRef.current = next;80 setPhase(next);81 };82 83 const later = (fn: () => void, ms: number) => {84 timers.current.push(window.setTimeout(fn, ms));85 };86 87 const succeed = () => {88 go("success");89 later(() => go("idle"), successHold);90 };91 92 const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {93 if (phaseRef.current !== "idle" || disabled) return;94 onClick?.(event);95 go("loading");96 if (onAction) {97 onAction().then(succeed, () => go("idle"));98 } else {99 later(succeed, duration);100 }101 };102 103 const content =104 phase === "loading" ? (105 <>106 <Spinner />107 {loadingLabel}108 </>109 ) : phase === "success" ? (110 <>111 <CheckIcon />112 {successLabel}113 </>114 ) : (115 children ?? label116 );117 118 return (119 <motion.button120 type="button"121 layout={!prefersReducedMotion}122 disabled={disabled}123 aria-busy={phase === "loading"}124 aria-live="polite"125 onClick={handleClick}126 data-phase={phase}127 transition={{ type: "spring", stiffness: 420, damping: 34 }}128 className={`${styles.root} ${className}`.trim()}129 >130 <AnimatePresence mode="popLayout" initial={false}>131 <motion.span132 key={phase}133 className={styles.content}134 initial={prefersReducedMotion ? { opacity: 0 } : { y: 18, opacity: 0 }}135 animate={{ y: 0, opacity: 1 }}136 exit={prefersReducedMotion ? { opacity: 0 } : { y: -18, opacity: 0 }}137 transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] }}138 >139 {content}140 </motion.span>141 </AnimatePresence>142 </motion.button>143 );144}