Progress Button
Buttons

Progress Button

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.

Install

npx shadcn@latest add @paragon/progress-button

Also installs: button

progress-button.tsx

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