Skip to content

Stateful Button

Idle, loading, success and reset, with a smooth width morph.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/stateful-button.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/stateful-button/stateful-button.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps, type MouseEvent, type ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring } from "@/lib/motion";
    
    export type StatefulButtonState = "idle" | "loading" | "success" | "error";
    
    export interface StatefulButtonProps
      extends Omit<ComponentProps<"button">, "onClick" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Runs on click. Return a promise to move through loading, success and error automatically. */
      onClick?: (event: MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
      /** Controlled state. Leave undefined to let the button manage it. */
      state?: StatefulButtonState;
      /** Called whenever the button moves to a new state. */
      onStateChange?: (state: StatefulButtonState) => void;
      /** Label while loading. @default "Loading" */
      loadingLabel?: ReactNode;
      /** Label after success. @default "Done" */
      successLabel?: ReactNode;
      /** Label after an error. @default "Try again" */
      errorLabel?: ReactNode;
      /** Milliseconds to show success or error before returning to idle. 0 keeps the result. @default 1800 */
      resetAfter?: number;
      /** Visual style. @default "solid" */
      variant?: "solid" | "primary" | "outline";
      /** Button size. @default "md" */
      size?: "sm" | "md" | "lg";
    }
    
    const variantClasses = {
      solid:
        "bg-foreground text-background shadow-[inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.14),0_4px_12px_-2px_rgb(0_0_0/0.12)]",
      primary:
        "bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),0_1px_2px_rgb(0_0_0/0.12),0_4px_12px_-2px_color-mix(in_oklab,var(--primary)_40%,transparent)]",
      outline: "border border-border bg-background text-foreground shadow-xs",
    } as const;
    
    const sizeClasses = {
      sm: "h-8 gap-1.5 px-3 text-[13px] after:absolute after:-inset-1 after:content-['']",
      md: "h-10 gap-2 px-4 text-sm",
      lg: "h-12 gap-2 px-6 text-[15px]",
    } as const;
    
    // Inline radius so Motion can correct it while the width animates.
    const radius = { sm: 10, md: 10, lg: 14 } as const;
    
    function Spinner() {
      return (
        // A CSS animation (pulsing under reduced motion via the media query), so markup never depends on JS state.
        <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="animate-spin motion-reduce:animate-pulse">
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
        </svg>
      );
    }
    
    function DrawnIcon({ paths }: { paths: string[] }) {
      return (
        <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
          {paths.map((d, i) => (
            <motion.path
              key={d}
              d={d}
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={{ pathLength: 0 }}
              animate={{ pathLength: 1 }}
              transition={{ duration: duration.base, ease: ease.out, delay: 0.08 + i * 0.08 }}
            />
          ))}
        </svg>
      );
    }
    
    const CHECK = ["M5 12.5l4.5 4.5L19 7.5"];
    const CROSS = ["M7 7l10 10", "M17 7L7 17"];
    
    export function StatefulButton({
      onClick,
      state,
      onStateChange,
      loadingLabel = "Loading",
      successLabel = "Done",
      errorLabel = "Try again",
      resetAfter = 1800,
      variant = "solid",
      size = "md",
      type = "button",
      className,
      style,
      children,
      ...props
    }: StatefulButtonProps) {
      const [internalState, setInternalState] = useState<StatefulButtonState>("idle");
      const current = state ?? internalState;
      const reduceMotion = useReducedMotion();
      const mounted = useRef(true);
      const onStateChangeRef = useRef(onStateChange);
    
      useEffect(() => {
        onStateChangeRef.current = onStateChange;
      });
    
      useEffect(() => {
        mounted.current = true;
        return () => {
          mounted.current = false;
        };
      }, []);
    
      const setState = (next: StatefulButtonState) => {
        if (!mounted.current) return;
        if (state === undefined) setInternalState(next);
        onStateChangeRef.current?.(next);
      };
    
      useEffect(() => {
        if ((current !== "success" && current !== "error") || resetAfter <= 0) return;
        const timer = window.setTimeout(() => {
          if (state === undefined) setInternalState("idle");
          onStateChangeRef.current?.("idle");
        }, resetAfter);
        return () => window.clearTimeout(timer);
      }, [current, resetAfter, state]);
    
      async function handleClick(event: MouseEvent<HTMLButtonElement>) {
        if (current !== "idle") return;
        const result = onClick?.(event);
        if (!result || typeof (result as Promise<unknown>).then !== "function") return;
        setState("loading");
        try {
          await result;
          setState("success");
        } catch {
          setState("error");
        }
      }
    
      const content: Variants = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: { opacity: 0, y: 10, filter: "blur(4px)" },
            animate: { opacity: 1, y: 0, filter: "blur(0px)" },
            exit: { opacity: 0, y: -10, filter: "blur(4px)" },
          };
    
      const label =
        current === "loading" ? (
          <>
            <Spinner />
            {loadingLabel}
          </>
        ) : current === "success" ? (
          <>
            <DrawnIcon paths={CHECK} />
            {successLabel}
          </>
        ) : current === "error" ? (
          <>
            <DrawnIcon paths={CROSS} />
            {errorLabel}
          </>
        ) : (
          children
        );
    
      const busy = current !== "idle";
    
      return (
        <>
          <motion.button
            type={type}
            layout={!reduceMotion}
            aria-busy={current === "loading"}
            aria-disabled={busy || undefined}
            onClick={handleClick}
            animate={current === "error" && !reduceMotion ? { x: [0, -6, 6, -4, 4, 0] } : { x: 0 }}
            transition={{ layout: spring.smooth, x: { duration: 0.4, ease: ease.out } }}
            style={{ borderRadius: radius[size], ...style }}
            className={cn(
              "relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center overflow-hidden font-medium whitespace-nowrap outline-none select-none",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              "disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-default [&_svg]:size-4 [&_svg]:shrink-0",
              variantClasses[variant],
              sizeClasses[size],
              className,
            )}
            {...props}
          >
            {/* Result tints fade in over the base color instead of animating background-color. */}
            <motion.span
              aria-hidden="true"
              className="absolute inset-0 -z-10 rounded-[inherit] bg-success"
              initial={false}
              animate={{ opacity: current === "success" ? 1 : 0 }}
              transition={{ duration: duration.base }}
            />
            <motion.span
              aria-hidden="true"
              className="absolute inset-0 -z-10 rounded-[inherit] bg-destructive"
              initial={false}
              animate={{ opacity: current === "error" ? 1 : 0 }}
              transition={{ duration: duration.base }}
            />
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={current}
                layout={!reduceMotion ? "position" : false}
                variants={content}
                initial="initial"
                animate="animate"
                exit="exit"
                transition={{ duration: duration.quick, ease: ease.out }}
                className={cn(
                  "inline-flex items-center gap-[inherit]",
                  (current === "success" || current === "error") && "text-white",
                )}
              >
                {label}
              </motion.span>
            </AnimatePresence>
          </motion.button>
          <span role="status" className="sr-only">
            {current === "loading" ? loadingLabel : current === "success" ? successLabel : current === "error" ? errorLabel : null}
          </span>
        </>
      );
    }

Usage

import { StatefulButton } from "@/components/ui/stateful-button/stateful-button";

export function SaveButton() {
  return (
    <StatefulButton onClick={() => saveSettings()} loadingLabel="Saving" successLabel="Saved">
      Save changes
    </StatefulButton>
  );
}

API reference

PropTypeDefaultDescription
onClick(event: MouseEvent<HTMLButtonElement>) => void | Promise<unknown>–Runs on click. Return a promise to move through loading, success and error automatically.
stateStatefulButtonState–Controlled state. Leave undefined to let the button manage it.
onStateChange(state: StatefulButtonState) => void–Called whenever the button moves to a new state.
loadingLabelReactNode"Loading"Label while loading.
successLabelReactNode"Done"Label after success.
errorLabelReactNode"Try again"Label after an error.
resetAfternumber1800Milliseconds to show success or error before returning to idle. 0 keeps the result.
variant"solid" | "primary" | "outline""solid"Visual style.
size"sm" | "md" | "lg""md"Button size.

Also accepts every native <button> attribute, including ref and className.

  • Return a promise from onClick to drive the states automatically: resolve for success, reject for error.
  • Pass `state` to control it yourself, for example when the result comes from a form library.
  • The width animates with a Motion layout animation, so the corner radius is set inline (10px, or 14px for lg) to stay round mid-animation.

Accessibility

  • While loading the button sets aria-busy and aria-disabled instead of disabled, so it keeps keyboard focus.
  • Clicks are ignored until the button is back to idle, which prevents double submits.
  • A polite status region announces the loading, success and error labels.
  • With reduced motion enabled, labels cross-fade, the width snaps, the error shake is skipped and the spinner pulses instead of rotating.