An icon-only button that runs an async action and walks idle → loading → success/error with the house icon swap, keeping focus and announcing each state politely.
npx shadcn@latest add @paragon/stateful-icon-button"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, LoaderCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type StatefulIconButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulIconButtonProps
extends Omit<React.ComponentProps<"button">, "children" | "aria-label"> {
/** Idle-state icon. */
icon: React.ReactNode;
/** Idle accessible name, e.g. "Retry delivery". */
label: string;
/** Controlled state. Leave undefined to drive via `onAction`. */
state?: StatefulIconButtonState;
onStateChange?: (state: StatefulIconButtonState) => void;
/** Async work to run on press; resolve → success, reject → error. */
onAction?: () => Promise<unknown> | unknown;
loadingIcon?: React.ReactNode;
successIcon?: React.ReactNode;
errorIcon?: React.ReactNode;
loadingLabel?: string;
successLabel?: string;
errorLabel?: string;
/** ms before success/error swap back to idle. */
resetDelay?: number;
/** Visual size; the hit target stays ≥ 40px for both. */
size?: "sm" | "default";
/** Disables the icon-swap motion; states still change. */
static?: boolean;
}
/**
* The copy-button recipe, generalized: an icon-only button that runs an
* async action and walks idle → loading → success/error → idle, each face
* swapping with the house icon swap (popLayout, scale 0.25, 4px blur,
* zero-bounce spring). Presses during flight are ignored rather than
* disabled, so focus never drops; the live state is announced politely.
*/
export function StatefulIconButton({
icon,
label,
state: stateProp,
onStateChange,
onAction,
loadingIcon,
successIcon,
errorIcon,
loadingLabel = "Working",
successLabel = "Done",
errorLabel = "Failed",
resetDelay = 1500,
size = "default",
static: isStatic = false,
className,
onClick,
disabled,
...props
}: StatefulIconButtonProps) {
const reduced = useReducedMotion() ?? false;
const [uncontrolled, setUncontrolled] =
React.useState<StatefulIconButtonState>("idle");
const state = stateProp ?? uncontrolled;
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);
};
}, []);
const setState = React.useCallback(
(next: StatefulIconButtonState) => {
if (stateProp === undefined) setUncontrolled(next);
onStateChange?.(next);
},
[stateProp, onStateChange],
);
const run = async () => {
if (!onAction) return;
setState("loading");
try {
await onAction();
if (!mounted.current) return;
setState("success");
} catch {
if (!mounted.current) return;
setState("error");
}
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => {
if (mounted.current) setState("idle");
}, resetDelay);
};
const faces: Record<StatefulIconButtonState, React.ReactNode> = {
idle: icon,
loading: loadingIcon ?? (
<LoaderCircle
aria-hidden
className="animate-spin motion-reduce:animate-none motion-reduce:opacity-60"
/>
),
success: successIcon ?? <Check aria-hidden />,
error: errorIcon ?? <X aria-hidden />,
};
const labels: Record<StatefulIconButtonState, string> = {
idle: label,
loading: loadingLabel,
success: successLabel,
error: errorLabel,
};
return (
<button
type="button"
data-slot="stateful-icon-button"
data-state={state}
aria-label={labels[state]}
aria-busy={state === "loading" || undefined}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (state !== "idle") return;
void run();
}}
className={cn(
"pressable relative inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none",
"transition-[color,background-color,scale] duration-150 ease-out",
"hover:bg-accent hover:text-foreground",
"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",
size === "sm" ? "size-7 [&_svg]:size-3.5" : "size-9 [&_svg]:size-4",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
state === "success" && "text-success hover:text-success",
state === "error" && "text-destructive hover:text-destructive",
isStatic && "active:not-disabled:scale-100",
className,
)}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={state}
className="flex items-center justify-center"
initial={
isStatic || reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
isStatic || reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={
isStatic || reduced
? { duration: 0.1 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
>
{faces[state]}
</motion.span>
</AnimatePresence>
<span aria-live="polite" className="sr-only">
{state === "idle" ? "" : labels[state]}
</span>
</button>
);
}