Stateful Icon Button
Buttons

Stateful Icon Button

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.

Install

npx shadcn@latest add @paragon/stateful-icon-button

stateful-icon-button.tsx

"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>
  );
}