Button loading choreography: the label blur-swaps to spinner plus label, width follows via a layout spring, and success shows a check for 1.5s before reverting.
npx shadcn@latest add @paragon/loading-button-setAlso installs: button
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, LoaderCircle } from "lucide-react";
import { Button, type ButtonProps } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";
const MotionButton = motion.create(Button);
export type LoadingButtonState = "idle" | "loading" | "success";
export interface LoadingButtonProps
extends Omit<
ButtonProps,
"onClick" | "asChild" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
> {
children: React.ReactNode;
/** Label while loading. Defaults to the idle label. */
loadingLabel?: React.ReactNode;
/** Label while showing success. */
successLabel?: React.ReactNode;
/** Controlled state. When set, the button never manages its own cycle. */
state?: LoadingButtonState;
/**
* Click handler. Return a promise (uncontrolled mode) and the button runs
* the full choreography: loading while pending, success on resolve,
* back to idle after `successDuration`. Rejections revert to idle.
*/
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
/** How long the success state holds before reverting, in ms. */
successDuration?: number;
}
/**
* Button loading choreography on top of the house `Button`.
*
* The label blur-swaps to spinner + label, the button width follows via a
* motion layout spring, and success swaps the spinner for a check before
* reverting after 1.5s. If `loadingLabel` matches the idle label the text
* holds still and only the spinner enters. Reduced motion swaps states
* instantly and freezes the spinner.
*/
export function LoadingButton({
children,
loadingLabel,
successLabel = "Done",
state: stateProp,
onClick,
successDuration = 1500,
disabled,
className,
...props
}: LoadingButtonProps) {
const reducedMotion = useReducedMotion() ?? false;
const [internalState, setInternalState] =
React.useState<LoadingButtonState>("idle");
const state = stateProp ?? internalState;
const revertTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
const mounted = React.useRef(true);
React.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (revertTimeout.current) clearTimeout(revertTimeout.current);
};
}, []);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (state !== "idle") return;
const result = onClick?.(event);
// Controlled, or a sync handler: nothing more to choreograph.
if (stateProp !== undefined || !(result instanceof Promise)) return;
setInternalState("loading");
result.then(
() => {
if (!mounted.current) return;
setInternalState("success");
revertTimeout.current = setTimeout(() => {
if (mounted.current) setInternalState("idle");
}, successDuration);
},
() => {
if (mounted.current) setInternalState("idle");
},
);
};
const label =
state === "loading"
? (loadingLabel ?? children)
: state === "success"
? successLabel
: children;
// Key by text when possible so an unchanged label holds still while the
// spinner enters beside it, instead of crossfading with itself.
const labelKey = typeof label === "string" ? label : state;
const swap = reducedMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.3, bounce: 0 } as const);
return (
<MotionButton
layout
transition={
reducedMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.4, bounce: 0 } as const)
}
className={cn("overflow-hidden", className)}
disabled={disabled || state === "loading"}
aria-busy={state === "loading"}
onClick={handleClick}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
{state !== "idle" && (
<motion.span
key={state === "loading" ? "spinner" : "check"}
layout
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={swap}
className="flex shrink-0 items-center justify-center"
>
{state === "loading" ? (
// Spins only while a task is pending, so the loop is bounded.
<LoaderCircle
aria-hidden
className="animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden />
)}
</motion.span>
)}
<motion.span
key={labelKey}
layout="position"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={swap}
className="whitespace-nowrap"
>
{label}
</motion.span>
</AnimatePresence>
</MotionButton>
);
}