Studio KitInteractive Buttons and Controlsmotion/react

LoadingButton

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 props
1600
1400

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add loading-button

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

example.tsx
1import { LoadingButton } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <LoadingButton />6  );7}

Props

PropTypeDefault
label

Idle label (ignored when children are provided).

string'Deploy to production'
children

Custom idle content.

React.ReactNodeundefined
loadingLabel

Text shown next to the spinner while loading.

string'Deploying'
successLabel

Text shown next to the check mark on success.

string'Deployed'
duration

Simulated work duration in ms, used when onAction is not provided.

number1600
successHold

How long the success state is held before returning to idle (ms).

number1400
disabled

Disables the button.

booleanfalse
onClick

Called when a new cycle starts.

(event: React.MouseEvent<HTMLButtonElement>) => voidundefined
onAction

Async work that drives the loading state; resolve shows success, reject returns to idle.

() => Promise<unknown>undefined
className

Extra class merged onto the button.

string''

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

components/ui/loading-button.tsx
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}