Skip to content

Command Palette

Search for a command to run...

2
All posts

Engineering

Shadcn Loading Button with Success and Error States

Build an accessible shadcn loading button with success and error states, duplicate-action protection, live updates, and reduced motion.

SS
Sourabh Soni

A shadcn loading button often starts with two props: add a spinner and set disabled. That covers one frame of the interaction. It does not answer what happens when the action succeeds, when it fails, or when someone clicks twice before React has rendered the disabled state.

The more useful model is a small state machine: idle, loading, success, and error. Each state owns one label, one icon, and one set of interaction rules. The result is easier to reason about than a collection of overlapping booleans.

This guide builds that complete async lifecycle as a shadcn-compatible, copy-paste React component.

Loading…

The left button resolves and the right button rejects. Both use the same component and deliberately keep the outcome visible before returning to idle.

Quick start

Install the Stateful Button component from the Tent UI registry:

pnpm dlx shadcn@latest add @tentui/stateful-button

Then give it a Promise-returning action:

import { StatefulButton } from "@/components/stateful-button";
 
export function SaveButton() {
  return (
    <StatefulButton
      labels={{
        loading: "Saving",
        success: "Saved",
        error: "Try again",
      }}
      onClick={async () => {
        await saveChanges();
      }}
    >
      Save changes
    </StatefulButton>
  );
}

The button enters loading immediately, waits for saveChanges(), shows the matching result, and resets after two seconds.

A spinner is not a state model

The official shadcn/ui Button documentation shows how to compose a Spinner inside a button. That is the right primitive when all you need is a visual loading indicator. Its documented Button API does not expose a loading prop, so the application still owns the async behavior.

That behavior becomes ambiguous when it is represented by independent values:

const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [hasError, setHasError] = useState(false);

Can loading and success both be true? Does an old error survive the next click? Which flag controls the label? Those combinations are possible even though the interface should never display them.

A single status removes the invalid combinations:

type Status = "idle" | "loading" | "success" | "error";

The allowed transitions are deliberately small:

idle -> loading -> success -> idle
                -> error   -> idle
StateVisible labelDisabledaria-busyExit condition
idleThe button's childrenNofalseThe action starts
loadingWorking or a custom labelYestrueThe Promise settles
successDone or a custom labelYesfalseFeedback timer ends
errorTry again or a custom labelYesfalseFeedback timer ends

Only loading means work is still pending. Success and error are short-lived feedback states, not extensions of the request.

Prevent the duplicate-click race

Disabling a button is necessary for most non-idempotent actions, but rendering disabled is not a synchronous lock. Two click events can arrive before React commits the loading state.

The component closes that gap with a ref:

const isRunning = useRef(false);
 
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  if (isRunning.current) return;
 
  isRunning.current = true;
  setStatus("loading");
 
  try {
    await onClick?.(event);
    setStatus("success");
  } catch {
    setStatus("error");
  }
}

The ref changes during the first event, before another event can pass the guard. State still drives what the reader sees; the ref protects the narrow window before that state reaches the DOM.

This matters for actions such as purchases, invitations, uploads, and saves. Even when the server accepts an idempotency key, preventing the duplicate at the interface produces clearer feedback.

Handle success and failure in one place

The Promise is the component's transition signal. A resolved Promise moves to success and a rejected Promise moves to error. After either result, a timer returns the button to idle.

Keep application errors observable. If your error reporter lives outside the component, report the failure and rethrow it so the button can still show its error state:

<StatefulButton
  onClick={async () => {
    try {
      await publishChanges();
    } catch (error) {
      reportError(error);
      throw error;
    }
  }}
>
  Publish
</StatefulButton>

Two seconds is long enough for brief acknowledgement without making the control feel stuck. It is not a universal duration. Reset immediately when the action navigates away, and keep persistent errors near the affected field or content when the user needs them to recover.

Announce status without moving focus

Visual feedback is only one channel. WCAG 2.2 Success Criterion 4.1.3 specifically covers waiting, success, and error messages that should be available to assistive technology without receiving focus.

The Stateful Button uses four complementary signals:

  • The visible label changes from the action to its current status.
  • aria-busy is true only while the Promise is unresolved.
  • A polite role="status" region exposes each non-idle label.
  • The spinner, check, and error icons are decorative because the text already communicates their meaning.
<button aria-busy={status === "loading"} disabled={status !== "idle"}>
  {label}
</button>
 
<span className="sr-only" role="status" aria-live="polite">
  {status === "idle" ? "" : label}
</span>

The live region is polite because this feedback usually does not justify interrupting the current announcement. An urgent, destructive failure may need a different error pattern outside the button.

Treat these semantics as a foundation, not an accessibility certificate. Test the final labels, surrounding form, focus behavior, and announcement timing in the browser and screen-reader combinations your product supports.

Keep the meaning when motion stops

Animation can make the state change feel continuous, but it must not carry the meaning by itself. The component wraps its character transitions in MotionConfig reducedMotion="user" and stops the spinner with Tailwind's motion-reduce:animate-none utility.

With reduced motion enabled, the text and icon still change. Only the transforms, blur, and rotation disappear. This follows the same rule described in Motion that earns its place: remove movement, not feedback.

Read the complete source

The implementation below includes the state model, labels, click guard, feedback timer, live region, icons, and reduced-motion behavior.

components/stateful-button.tsx
"use client";
 
import { CheckIcon, LoaderCircleIcon, XIcon } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
 
import { cn } from "@/lib/utils";
 
const FEEDBACK_DURATION = 2000;
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
 
type StatefulButtonStatus = "idle" | "loading" | "success" | "error";
 
type StatefulButtonLabels = Partial<
	Record<Exclude<StatefulButtonStatus, "idle">, string>
>;
 
interface StatefulButtonProps
	extends Omit<React.ComponentProps<"button">, "children" | "onClick"> {
	children: string;
	labels?: StatefulButtonLabels;
	onClick?: (
		event: React.MouseEvent<HTMLButtonElement>,
	) => void | Promise<void>;
}
 
const defaultLabels = {
	loading: "Working",
	success: "Done",
	error: "Try again",
} satisfies Record<Exclude<StatefulButtonStatus, "idle">, string>;
 
function AnimatedChars({ text }: { text: string }) {
	return (
		<AnimatePresence mode="popLayout" initial={false}>
			{Array.from(text, (char, offset) => (
				<motion.span
					key={`${char}-${offset}`}
					layout
					initial={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
					animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
					exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
					transition={{
						type: "spring",
						stiffness: 500,
						damping: 30,
						mass: 1,
					}}
					className="inline-block"
				>
					{char === " " ? "\u00A0" : char}
				</motion.span>
			))}
		</AnimatePresence>
	);
}
 
function StatefulButton({
	children,
	className,
	disabled,
	labels,
	onClick,
	...props
}: StatefulButtonProps) {
	const [status, setStatus] = useState<StatefulButtonStatus>("idle");
	const isRunning = useRef(false);
	const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
	const resolvedLabels = { idle: children, ...defaultLabels, ...labels };
	const label = resolvedLabels[status];
	const isPending = status !== "idle";
 
	useEffect(
		() => () => {
			if (resetTimer.current) clearTimeout(resetTimer.current);
		},
		[],
	);
 
	async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
		if (isRunning.current) return;
 
		isRunning.current = true;
		setStatus("loading");
 
		try {
			await onClick?.(event);
			setStatus("success");
		} catch {
			setStatus("error");
		}
 
		resetTimer.current = setTimeout(() => {
			isRunning.current = false;
			setStatus("idle");
		}, FEEDBACK_DURATION);
	}
 
	return (
		<MotionConfig reducedMotion="user">
			<div className="relative inline-flex font-sans">
				<button
					data-slot="stateful-button"
					data-status={status}
					type="button"
					aria-busy={status === "loading"}
					disabled={disabled || isPending}
					onClick={handleClick}
					className={cn(
						"relative inline-flex h-12 min-w-36 select-none items-center justify-center rounded-full px-8 font-medium text-base outline-none duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-3 focus-visible:ring-ring/50 active:not-disabled:scale-[0.97] motion-reduce:transform-none motion-reduce:transition-none",
						status === "idle" &&
							"bg-linear-to-b from-blue-400 to-blue-500 text-primary-foreground hover:from-blue-500 hover:to-blue-600 hover:shadow-sm",
						status !== "idle" &&
							"cursor-not-allowed bg-muted/60 text-muted-foreground",
						className,
					)}
					{...props}
				>
					<span className="flex items-center justify-center">
						<AnimatedChars text={label} />
					</span>
				</button>
 
				<AnimatePresence initial={false}>
					{status !== "idle" && (
						<motion.span
							initial={{ opacity: 0, scale: 0.94, x: -4 }}
							animate={{ opacity: 1, scale: 1, x: 0 }}
							exit={{ opacity: 0, scale: 0.94, x: -4 }}
							transition={{ duration: 0.18, ease: EASE_OUT }}
							aria-hidden="true"
							className={cn(
								"pointer-events-none absolute -top-1 -right-1 flex size-6 items-center justify-center overflow-hidden rounded-full",
								status === "loading" && "bg-muted text-muted-foreground",
								status === "success" && "bg-success text-white",
								status === "error" && "bg-destructive text-white",
							)}
						>
							<AnimatePresence initial={false} mode="wait">
								<motion.span
									key={status}
									initial={{ opacity: 0, scale: 0.94 }}
									animate={{ opacity: 1, scale: 1 }}
									exit={{ opacity: 0, scale: 0.94 }}
									transition={{ duration: 0.12, ease: EASE_OUT }}
									className="absolute inset-0 flex items-center justify-center [&_svg]:size-3.5"
								>
									{status === "loading" && (
										<LoaderCircleIcon className="animate-spin motion-reduce:animate-none" />
									)}
									{status === "success" && <CheckIcon />}
									{status === "error" && <XIcon />}
								</motion.span>
							</AnimatePresence>
						</motion.span>
					)}
				</AnimatePresence>
 
				<span className="sr-only" role="status" aria-live="polite">
					{status === "idle" ? "" : label}
				</span>
			</div>
		</MotionConfig>
	);
}
 
export {
	StatefulButton,
	type StatefulButtonLabels,
	type StatefulButtonProps,
	type StatefulButtonStatus,
};
 
export default StatefulButton;

Because Tent UI distributes source through a shadcn registry, this file lands inside your project. You can replace its native-button styles with your local Button variants, change the feedback duration, or remove the character animation without waiting for a package API. That is the practical advantage of owning a copy-paste component.

When not to use this loading button

This pattern is for short, user-initiated async actions. Choose another pattern when the job has different semantics:

  • Use a progress indicator with a percentage for long, measurable work.
  • Keep a cancel control enabled when the operation can safely be interrupted.
  • Use a link for navigation instead of making a button imitate one.
  • Put validation errors beside the invalid field when the button cannot explain how to recover.
  • Use a confirmation dialog before a destructive action when the decision, not the network request, needs extra attention.

The component also defaults to type="button". It is not a drop-in replacement for a React Server Action submit button or useFormStatus. Form submission owns pending state differently, and it should be tested with the form's validation, focus, and progressive-enhancement behavior.

Test behavior, not the spinner

Before shipping an async button, verify the state transitions rather than only checking whether an icon rendered:

ScenarioExpected behavior
Rapid activationThe action handler runs once
Keyboard activationEnter and Space start one action when the button is idle
Pending actionThe loading label appears and the control is unavailable
Resolved PromiseSuccess is visible and announced before reset
Rejected PromiseError is visible and announced before retry becomes available
Reduced motionStatus remains clear without transforms or spinner rotation
Narrow layout and zoomCustom labels remain readable without clipping
Component unmountNo delayed feedback is shown after the surface disappears

Use deterministic Promises in component tests. Resolve or reject them manually instead of waiting on real network requests, and use fake timers for the feedback reset. Run a separate manual check with your supported screen readers; a DOM assertion cannot tell you whether an announcement is understandable in context.

Frequently asked questions

Does shadcn/ui have a loading button prop?

The current documented Button API lists variants and sizes, not a loading prop. The official examples compose the separate Spinner component inside a button. Use that simple composition for visual loading, or add an explicit state model when the action also needs duplicate prevention and result feedback.

Should a loading button be disabled?

Usually, yes, when repeating the action would be harmful or confusing. A synchronous guard is still useful because a second event can occur before the disabled render commits. Do not disable the only way to cancel a long-running operation.

Should a loading button use aria-live?

Use a status message when the waiting or result text needs to be announced without moving focus. role="status" provides polite live-region semantics. Keep the message concise and avoid announcing the decorative spinner separately.

How long should the success state remain visible?

For a brief in-place action, one to two seconds is a reasonable starting point. The Stateful Button uses two seconds. Let the next screen communicate success when the action navigates, and keep recovery instructions persistent when the error requires user input.

Copy, then adapt

A good loading button does more than rotate an icon. It prevents impossible states, closes the rapid-click gap, communicates the result through more than motion, and gives failure a clear path back to idle.

Copy the Stateful Button source and try both outcomes, or browse the Tailwind CSS button gallery if you need a visual starting point before adding async behavior.

Command Palette

Search for a command to run...