Buttons & Actionsv0.1.0
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.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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); }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
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (event: MouseEvent<HTMLButtonElement>) => void | Promise<unknown> | – | Runs on click. Return a promise to move through loading, success and error automatically. |
state | StatefulButtonState | – | Controlled state. Leave undefined to let the button manage it. |
onStateChange | (state: StatefulButtonState) => void | – | Called whenever the button moves to a new state. |
loadingLabel | ReactNode | "Loading" | Label while loading. |
successLabel | ReactNode | "Done" | Label after success. |
errorLabel | ReactNode | "Try again" | Label after an error. |
resetAfter | number | 1800 | Milliseconds 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.