Loaders & Skeletons

Loading Button Set

Button loading choreography: the label blur-swaps to spinner plus label, width follows via a layout spring, and success shows a check for 1.5s 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 } 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";

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;
  /** 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,
   * back to idle after `successDuration`. Rejections revert to idle.
   */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
  /** How long the success 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 success swaps the spinner for a check before
 * reverting after 1.5s. If `loadingLabel` matches the idle label the text
 * holds still and only the spinner enters. Reduced motion swaps states
 * instantly and freezes the spinner.
 */
export function LoadingButton({
  children,
  loadingLabel,
  successLabel = "Done",
  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 handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    if (state !== "idle") return;
    const result = onClick?.(event);
    // Controlled, or a sync handler: nothing more to choreograph.
    if (stateProp !== undefined || !(result instanceof Promise)) return;
    setInternalState("loading");
    result.then(
      () => {
        if (!mounted.current) return;
        setInternalState("success");
        revertTimeout.current = setTimeout(() => {
          if (mounted.current) setInternalState("idle");
        }, successDuration);
      },
      () => {
        if (mounted.current) setInternalState("idle");
      },
    );
  };

  const label =
    state === "loading"
      ? (loadingLabel ?? children)
      : state === "success"
        ? successLabel
        : 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 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", className)}
      disabled={disabled || state === "loading"}
      aria-busy={state === "loading"}
      onClick={handleClick}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {state !== "idle" && (
          <motion.span
            key={state === "loading" ? "spinner" : "check"}
            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 only while a task is pending, so the loop is bounded.
              <LoaderCircle
                aria-hidden
                className="animate-spin motion-reduce:animate-none"
              />
            ) : (
              <Check 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>
  );
}