Loading Button
pnpm dlx shadcn@latest add …/r/loading-button.jsonAn accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.
import { LoadingButton } from "@/components/ui/loading-button"
export function SaveButton() {
return (
<LoadingButton
onClick={() => saveChanges()}
loadingText="Saving…"
successText="Saved"
errorText="Try again"
>
Save changes
</LoadingButton>
)
}"use client";
import { LoadingButton } from "@/components/ui/loading-button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";
const delays = [
{ ms: "800", label: "Quick · 0.8s" },
{ ms: "1500", label: "Medium · 1.5s" },
{ ms: "3000", label: "Slow · 3s" },
];
/** Pretend work that settles after `ms`, one way or the other */
const work = (ms: number, succeed: boolean) =>
new Promise<void>((resolve, reject) =>
setTimeout(() => (succeed ? resolve() : reject(new Error("Failed"))), ms),
);
export default function LoadingButtonAsync() {
const id = useId();
const [delay, setDelay] = useState("1500");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-delay`} className="text-sm text-muted-foreground">
Work takes
</label>
<Select value={delay} onValueChange={setDelay}>
<SelectTrigger id={`${id}-delay`} size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{delays.map((option) => (
<SelectItem key={option.ms} value={option.ms}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-wrap items-start justify-center gap-x-6 gap-y-4">
<div className="flex flex-col items-center gap-1.5">
<LoadingButton
onClick={() => work(Number(delay), true)}
loadingText="Saving…"
successText="Saved"
aria-describedby={`${id}-saves`}
>
Save changes
</LoadingButton>
<span id={`${id}-saves`} className="text-xs text-muted-foreground">
Succeeds
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
<LoadingButton
variant="outline"
onClick={() => work(Number(delay), false)}
loadingText="Publishing…"
errorText="Try again"
aria-describedby={`${id}-fails`}
>
Publish
</LoadingButton>
<span id={`${id}-fails`} className="text-xs text-muted-foreground">
Fails
</span>
</div>
</div>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button, type ButtonProps } from "@/components/ui/button"; import { duration, ease, spring, swap } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react"; /* * A Button that shows the work it starts: idle, loading, success, error (owner, 2026-10-14; Lab only * until it's approved, so there's no meta file yet). * * - State: control it with `state`, or leave it out and return a promise from `onClick`. Loading lasts * until the promise settles, then success or error shows, and the button returns to idle after * `resetAfter`. * - Motion (.agents/rules/motion.md): the content swaps through a blur with a small rise (owner, * 2026-10-14): the old blurs and lifts away as the new rises in from blurred. The spinner turns; the * check and cross draw themselves in. With reduced motion the content only fades and the spinner pulses. * - Width never jumps: every state is laid out invisibly in the same cell, so the button is as wide as its * widest state. * - Error is solid red (owner, 2026-10-14), just the text on ghost and link. Success stays neutral, with * the check carrying the meaning. * - Busy, it stays focusable (aria-disabled, not disabled) and ignores clicks, and a polite live region * says what happened. */ export type LoadingButtonState = "idle" | "loading" | "success" | "error"; export type LoadingButtonProps = Omit<ButtonProps, "onClick" | "asChild"> & { /** Control the state yourself. Leave it out and a click that returns a promise drives it. */ state?: LoadingButtonState; /** Return a promise to show loading until it settles, then success or error */ onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown; /** Shown while loading, next to the spinner; the label stays if left out */ loadingText?: ReactNode; /** Shown on success, next to the check */ successText?: ReactNode; /** Shown on error, next to the cross */ errorText?: ReactNode; /** After success or error, milliseconds until it's idle again; `false` to stay. Ignored when controlled. */ resetAfter?: number | false; }; const CHECK = "M5 12.5l4.5 4.5L19 7.5"; const CROSS = "M7 7l10 10M17 7L7 17"; /** A ring with a turning arc; with reduced motion it pulses instead */ function Spinner({ reduce }: { reduce: boolean }) { return ( <motion.svg viewBox="0 0 24 24" fill="none" aria-hidden animate={reduce ? { opacity: [1, 0.4, 1] } : { rotate: 360 }} transition={ reduce ? { duration: duration.slower * 3, repeat: Number.POSITIVE_INFINITY, ease: ease.inOut } : // One turn a second, at a constant speed { duration: duration.slower * 2, repeat: Number.POSITIVE_INFINITY, ease: "linear" } } > <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> </motion.svg> ); } /** A stroke that draws itself in (the check or the cross); a 16px icon, so drawing it costs nothing */ function Mark({ d, reduce }: { d: string; reduce: boolean }) { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden> <motion.path d={d} stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }} /> </svg> ); } const isPromise = (value: unknown): value is PromiseLike<unknown> => typeof (value as PromiseLike<unknown> | null)?.then === "function"; /** What a state shows. Sizers get a blank square for the icon, so nothing animates out of sight. */ function contentOf( state: LoadingButtonState, { children, loadingText, successText, errorText, }: Pick<LoadingButtonProps, "children" | "loadingText" | "successText" | "errorText">, icons: { reduce: boolean } | null, ) { const icon = (live: ReactNode) => (icons ? live : <span className="size-(--size-icon-md) shrink-0" />); switch (state) { case "loading": return ( <> {icon(<Spinner reduce={icons?.reduce ?? false} />)} {loadingText ?? children} </> ); case "success": return ( <> {icon(<Mark d={CHECK} reduce={icons?.reduce ?? false} />)} {successText ?? children} </> ); case "error": return ( <> {icon(<Mark d={CROSS} reduce={icons?.reduce ?? false} />)} {errorText ?? children} </> ); default: return children; } } const STATES: LoadingButtonState[] = ["idle", "loading", "success", "error"]; const ANNOUNCEMENTS: Record<LoadingButtonState, string> = { idle: "", loading: "Loading", success: "Done", error: "Something went wrong", }; function LoadingButton({ state, onClick, children, loadingText, successText, errorText, resetAfter = 2000, variant, className, ...props }: LoadingButtonProps) { const [internal, setInternal] = useState<LoadingButtonState>("idle"); const current = state ?? internal; const reduce = useReducedMotion() ?? false; const mounted = useRef(true); const texts = { children, loadingText, successText, errorText }; useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); // Uncontrolled, success and error give way to idle after a moment useEffect(() => { if (state !== undefined || resetAfter === false) return; if (internal !== "success" && internal !== "error") return; const timer = setTimeout(() => setInternal("idle"), resetAfter); return () => clearTimeout(timer); }, [internal, state, resetAfter]); const busy = current === "loading"; // Error is solid red (owner, 2026-10-14). Ghost and link keep their surface, so they turn only their text red. const errorClass = variant === "ghost" || variant === "link" ? "text-destructive" : "border-transparent bg-destructive text-destructive-foreground"; // With reduced motion: a plain fade, no blur or movement const motionProps = reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : swap; return ( <> <Button data-slot="loading-button" data-state={current} variant={variant} aria-busy={busy || undefined} aria-disabled={busy || undefined} className={cn(current === "error" && errorClass, busy && "cursor-progress", className)} onClick={(event) => { if (busy) { event.preventDefault(); return; } const result = onClick?.(event); if (state !== undefined || !isPromise(result)) return; setInternal("loading"); result.then( () => mounted.current && setInternal("success"), () => mounted.current && setInternal("error"), ); }} {...props} > <span className="grid"> {/* Every state, invisible, in one cell: the button is as wide as the widest */} {STATES.map((name) => ( <span key={name} aria-hidden className="invisible col-start-1 row-start-1 flex items-center justify-center gap-2" > {contentOf(name, texts, null)} </span> ))} <AnimatePresence initial={false}> <motion.span key={current} {...motionProps} transition={ reduce ? { duration: duration.fast } : { duration: duration.base, ease: ease.out, y: spring.snappy } } className="col-start-1 row-start-1 flex items-center justify-center gap-2" > {contentOf(current, texts, { reduce })} </motion.span> </AnimatePresence> </span> </Button> <span aria-live="polite" className="sr-only"> {ANNOUNCEMENTS[current]} </span> </> ); } export { LoadingButton };Update the import paths to match your project.
Examples
Every state
Set state yourself to control it. The width never changes, because the button is sized to its widest state.
- Idle
- LoadingLoading
- DoneSuccess
- Something went wrongError
import { LoadingButton, type LoadingButtonState } from "@/components/ui/loading-button";
import { useId } from "react";
const states: { state: LoadingButtonState; label: string }[] = [
{ state: "idle", label: "Idle" },
{ state: "loading", label: "Loading" },
{ state: "success", label: "Success" },
{ state: "error", label: "Error" },
];
export default function LoadingButtonStates() {
const id = useId();
return (
<ul className="grid w-fit grid-cols-2 gap-x-4 gap-y-5 sm:grid-cols-4">
{states.map(({ state, label }) => (
<li key={state} className="flex flex-col items-center gap-1.5">
<LoadingButton
state={state}
loadingText="Saving…"
successText="Saved"
errorText="Try again"
aria-describedby={`${id}-${state}`}
>
Save changes
</LoadingButton>
<span id={`${id}-${state}`} className="text-xs text-muted-foreground">
{label}
</span>
</li>
))}
</ul>
);
}API reference
Also accepts every Button prop except asChild. While loading it stays focusable (aria-busy, aria-disabled) and ignores clicks, and a polite live region announces each change.
| Prop | Type | Default | Description |
|---|---|---|---|
onClick? | (event) => unknown | – | Return a promise to show loading until it settles, then success when it resolves or error when it rejects. |
state? | "idle" | "loading" | "success" | "error" | – | Controls the state yourself. Leave it out and onClick's promise drives it. |
loadingText? | ReactNode | – | Shown next to the spinner. The label stays if left out. |
successText? | ReactNode | – | Shown next to the check. |
errorText? | ReactNode | – | Shown next to the cross, on solid red. |
resetAfter? | number | false | 2000 | Milliseconds after success or error until it's idle again, or false to stay. Ignored when controlled. |
variant? | Button variant | "default" | The button style. |
size? | Button size | "md" | The button size. |
Related components
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
An accessible React dark mode toggle for Next.js that reveals the new theme from the button with a View Transition: a circle, ink, a wave or a page turn.
- Ghost
- Outline
- With text
An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".