Status Button

A shadcn Button-based action that transitions through idle, loading, and success states.

buttonstate-transition
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ButtonProps = React.ComponentPropsWithoutRef<typeof Button>;
type ButtonClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
type ButtonState = "idle" | "loading" | "success";

export type StatusButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
  idleLabel?: React.ReactNode;
  loadingLabel?: React.ReactNode;
  successLabel?: React.ReactNode;
  loadingDuration?: number;
  successDuration?: number;
  onClick?: (event: ButtonClickEvent) => void | Promise<void>;
};

function Spinner({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "size-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-70",
        className
      )}
    />
  );
}

export function StatusButton({
  idleLabel = "Send me a login link",
  loadingLabel = <Spinner />,
  successLabel = "Login link sent!",
  loadingDuration = 1750,
  successDuration = 1750,
  disabled,
  className,
  onClick,
  type = "button",
  ...props
}: StatusButtonProps) {
  const [buttonState, setButtonState] = React.useState<ButtonState>("idle");
  const shouldReduceMotion = useReducedMotion();
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);

  React.useEffect(() => {
    return () => {
      timers.current.forEach(clearTimeout);
    };
  }, []);

  const clearTimers = React.useCallback(() => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
  }, []);

  const handleClick = React.useCallback(
    async (event: ButtonClickEvent) => {
      if (buttonState !== "idle") return;

      setButtonState("loading");
      clearTimers();

      try {
        await onClick?.(event);
      } catch {
        setButtonState("idle");
        return;
      }

      timers.current = [
        setTimeout(() => {
          setButtonState("success");
        }, loadingDuration),
        setTimeout(() => {
          setButtonState("idle");
        }, loadingDuration + successDuration),
      ];
    },
    [buttonState, clearTimers, loadingDuration, onClick, successDuration]
  );

  const copy: Record<ButtonState, React.ReactNode> = {
    idle: idleLabel,
    loading: loadingLabel,
    success: successLabel,
  };

  const transition = shouldReduceMotion
    ? { duration: 0 }
    : ({ type: "spring", duration: 0.3, bounce: 0 } as const);

  return (
    <Button
      type={type}
      disabled={disabled || buttonState === "loading"}
      onClick={handleClick}
      className={cn("min-w-44 overflow-hidden", className)}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={buttonState}
          initial={shouldReduceMotion ? false : { opacity: 0, y: -24 }}
          animate={{ opacity: 1, y: 0 }}
          exit={shouldReduceMotion ? undefined : { opacity: 0, y: 24 }}
          transition={transition}
          className="inline-flex items-center justify-center gap-1.5"
        >
          {copy[buttonState]}
        </motion.span>
      </AnimatePresence>
      <span role="status" aria-live="polite" className="sr-only">
        {buttonState === "loading"
          ? "Sending login link"
          : buttonState === "success"
            ? "Login link sent"
            : ""}
      </span>
    </Button>
  );
}

Installation

pnpm dlx shadcn@latest add https://ui.ericts.com/r/status-button.json