A button that carries its async lifecycle inside itself: a determinate transform-only fill (or spinner), sprung face swaps to success or error, a one-shot error shake, and auto-reset.
npx shadcn@latest add @paragon/progress-buttonAlso installs: button
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, LoaderCircle, RotateCcw } from "lucide-react";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/paragon/ui/button";
const progressButtonStyles = `
@keyframes pg-progress-shake {
0% { translate: 0; }
25% { translate: -5px 0; }
50% { translate: 4px 0; }
75% { translate: -2px 0; }
100% { translate: 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="progress-button"] { animation: none !important; }
[data-slot="progress-button"] .pg-progress-spin { animation: none !important; opacity: 0.6; }
}
`;
export type ProgressButtonState = "idle" | "loading" | "success" | "error";
export interface ProgressButtonProps
extends Omit<
React.ComponentProps<"button">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
>,
VariantProps<typeof buttonVariants> {
/** Controlled lifecycle state. Leave undefined to drive via `onAction`. */
state?: ProgressButtonState;
onStateChange?: (state: ProgressButtonState) => void;
/**
* Async work to run on press. Resolution lands on success, rejection on
* error; both auto-reset to idle after `resetDelay`.
*/
onAction?: () => Promise<unknown> | unknown;
/** 0–100. When set while loading, renders a determinate surface fill. */
progress?: number;
/** Loading face label. Defaults to the idle children. */
loadingLabel?: React.ReactNode;
successLabel?: React.ReactNode;
errorLabel?: React.ReactNode;
/** ms before success/error faces return to idle. */
resetDelay?: number;
/** Disables face motion and the width spring; states still swap. */
static?: boolean;
}
/**
* A button that carries its async lifecycle inside itself: press starts the
* work, a determinate fill sweeps the surface as `progress` advances (or a
* spinner for unknown durations), and the face swaps to success or error
* before settling back to idle. Width changes between faces are sprung, the
* fill is a transform so re-renders stay cheap, and an error shakes once.
* The button stays focused throughout — only idle and error accept presses.
*/
export function ProgressButton({
state: stateProp,
onStateChange,
onAction,
progress,
loadingLabel,
successLabel = "Done",
errorLabel = "Try again",
resetDelay = 2000,
static: isStatic = false,
variant,
size,
className,
children,
disabled,
onClick,
...props
}: ProgressButtonProps) {
const reduced = useReducedMotion() ?? false;
const immediate = isStatic || reduced;
const [uncontrolled, setUncontrolled] = React.useState<ProgressButtonState>("idle");
const state = stateProp ?? uncontrolled;
const stateRef = React.useRef(state);
stateRef.current = state;
const [shaking, setShaking] = React.useState(false);
const resetTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const mounted = React.useRef(true);
React.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (resetTimer.current) clearTimeout(resetTimer.current);
};
}, []);
React.useEffect(() => {
if (state === "error") setShaking(true);
}, [state]);
const setState = React.useCallback(
(next: ProgressButtonState) => {
if (stateProp === undefined) setUncontrolled(next);
onStateChange?.(next);
},
[stateProp, onStateChange],
);
const scheduleReset = React.useCallback(
(delay: number) => {
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => {
if (mounted.current) setState("idle");
}, delay);
},
[setState],
);
const run = async () => {
if (!onAction) return;
setState("loading");
try {
await onAction();
if (!mounted.current) return;
setState("success");
scheduleReset(resetDelay);
} catch {
if (!mounted.current) return;
setState("error");
scheduleReset(resetDelay * 1.5);
}
};
const determinate = state === "loading" && typeof progress === "number";
const fill = determinate ? Math.min(100, Math.max(0, progress)) : 0;
const faces: Record<ProgressButtonState, React.ReactNode> = {
idle: children,
loading: (
<>
<LoaderCircle aria-hidden className="pg-progress-spin animate-spin" />
{loadingLabel ?? children}
{determinate && (
<span className="text-[0.92em] opacity-80 tabular-nums">
{Math.round(fill)}%
</span>
)}
</>
),
success: (
<>
<Check aria-hidden />
{successLabel}
</>
),
error: (
<>
<RotateCcw aria-hidden />
{errorLabel}
</>
),
};
const statusText: Record<ProgressButtonState, string> = {
idle: "",
loading: determinate ? `Loading, ${Math.round(fill)}%` : "Loading",
success: "Complete",
error: "Failed",
};
return (
<>
<style href="paragon-progress-button" precedence="paragon">
{progressButtonStyles}
</style>
<motion.button
type="button"
data-slot="progress-button"
data-state={state}
layout={!immediate}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
aria-busy={state === "loading" || undefined}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (state === "loading" || state === "success") return;
void run();
}}
onAnimationEnd={(event) => {
if (event.animationName === "pg-progress-shake") setShaking(false);
}}
style={
shaking && !immediate
? { animation: "pg-progress-shake 260ms var(--ease-out) both" }
: undefined
}
className={cn(
buttonVariants({ variant, size }),
"relative overflow-hidden",
state === "error" &&
(variant === "outline" || variant === "ghost" || variant === "secondary") &&
"text-destructive",
!isStatic &&
state !== "loading" &&
"active:not-disabled:scale-[0.97]",
className,
)}
{...props}
>
{/* Determinate fill — a transform-only sweep under the label. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 origin-left rounded-[inherit] bg-current",
"transition-[scale,opacity] duration-300 ease-(--ease-out)",
determinate ? "opacity-15" : "opacity-0",
)}
style={{ scale: `${fill / 100} 1` }}
/>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={state}
className="relative inline-flex items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0"
initial={
immediate
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.9, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={
immediate
? { opacity: 0 }
: { opacity: 0, y: -10, scale: 0.9, filter: "blur(4px)" }
}
transition={
immediate
? { duration: 0.1 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
>
{faces[state]}
</motion.span>
</AnimatePresence>
<span aria-live="polite" className="sr-only">
{statusText[state]}
</span>
</motion.button>
</>
);
}