Loading Button Set
Loaders & Skeletons

Loading Button Set

Button loading choreography: the label blur-swaps to spinner plus label, width follows via a layout spring, and success or a retryable error state shows before reverting.

Install

npx shadcn@latest add @paragon/loading-button-set

Also installs: button

loading-button-set.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, LoaderCircle, X } 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" | "error";

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;
  /** Label while showing the error state. */
  errorLabel?: 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, error
   * on reject — either reverts to idle after `successDuration`.
   */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
  /** How long the success or error 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 settling swaps the spinner for a check (success)
 * or an X on a destructive tint (error) before reverting after 1.5s. An
 * errored button stays clickable, so users can retry immediately. If
 * `loadingLabel` matches the idle label the text holds still and only the
 * spinner enters. State changes are mirrored to a polite live region;
 * reduced motion swaps states instantly and freezes the spinner.
 */
export function LoadingButton({
  children,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  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 settle = (next: Extract<LoadingButtonState, "success" | "error">) => {
    if (!mounted.current) return;
    setInternalState(next);
    revertTimeout.current = setTimeout(() => {
      if (mounted.current) setInternalState("idle");
    }, successDuration);
  };

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    // Errored buttons accept a retry click; busy/settling ones do not.
    if (state === "loading" || state === "success") return;
    if (revertTimeout.current) clearTimeout(revertTimeout.current);
    const result = onClick?.(event);
    // Controlled, or a sync handler: nothing more to choreograph.
    if (stateProp !== undefined || !(result instanceof Promise)) return;
    setInternalState("loading");
    result.then(
      () => settle("success"),
      () => settle("error"),
    );
  };

  const label =
    state === "loading"
      ? (loadingLabel ?? children)
      : state === "success"
        ? successLabel
        : state === "error"
          ? errorLabel
          : 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 srStatus =
    state === "idle"
      ? ""
      : typeof label === "string"
        ? label
        : state === "loading"
          ? "Loading"
          : state === "success"
            ? "Done"
            : "Failed";

  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",
        state === "error" &&
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        className,
      )}
      disabled={disabled || state === "loading"}
      aria-busy={state === "loading"}
      data-state={state}
      onClick={handleClick}
      {...props}
    >
      <span role="status" className="sr-only">
        {srStatus}
      </span>
      <AnimatePresence mode="popLayout" initial={false}>
        {state !== "idle" && (
          <motion.span
            key={state}
            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 at the house 600ms/rev, only while a task is pending,
              // so the loop is bounded.
              <LoaderCircle
                aria-hidden
                className="animate-spin [animation-duration:600ms] motion-reduce:animate-none"
              />
            ) : state === "success" ? (
              <Check aria-hidden />
            ) : (
              <X 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>
  );
}