Loading Button
Original · freeA button that shows a spinner and disables itself while loading.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/btn-loading.json"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type Status = "idle" | "loading" | "success";
type Variant = "solid" | "outline" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
function Spinner({ className = "" }: { className?: string }) {
return (
<svg
className={`btnld-spin ${className}`}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2.5"
className="opacity-25"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
);
}
function Check({ className = "" }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M20 6 9 17l-5-5"
stroke="currentColor"
strokeWidth="2.75"
strokeLinecap="round"
strokeLinejoin="round"
className="btnld-check"
pathLength={1}
/>
</svg>
);
}
const sizeMap: Record<Size, string> = {
sm: "h-9 px-3.5 text-sm gap-1.5 rounded-lg",
md: "h-11 px-5 text-sm gap-2 rounded-xl",
lg: "h-13 px-7 text-base gap-2.5 rounded-2xl",
};
const iconSizeMap: Record<Size, string> = {
sm: "h-4 w-4",
md: "h-[18px] w-[18px]",
lg: "h-5 w-5",
};
const variantMap: Record<Variant, string> = {
solid:
"bg-indigo-600 text-white shadow-sm shadow-indigo-600/25 hover:bg-indigo-500 active:bg-indigo-700 focus-visible:ring-indigo-500 disabled:hover:bg-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:active:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
outline:
"border border-slate-300 bg-white text-slate-800 shadow-sm hover:bg-slate-50 hover:border-slate-400 active:bg-slate-100 focus-visible:ring-slate-400 disabled:hover:bg-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800 dark:hover:border-slate-600 dark:active:bg-slate-800 dark:disabled:hover:bg-slate-900",
ghost:
"text-slate-700 hover:bg-slate-100 active:bg-slate-200 focus-visible:ring-slate-400 disabled:hover:bg-transparent dark:text-slate-200 dark:hover:bg-slate-800 dark:active:bg-slate-700 dark:disabled:hover:bg-transparent",
danger:
"bg-rose-600 text-white shadow-sm shadow-rose-600/25 hover:bg-rose-500 active:bg-rose-700 focus-visible:ring-rose-500 disabled:hover:bg-rose-600 dark:bg-rose-500 dark:hover:bg-rose-400 dark:active:bg-rose-600 dark:disabled:hover:bg-rose-500",
};
const successMap: Record<Variant, string> = {
solid:
"bg-emerald-600 text-white shadow-sm shadow-emerald-600/25 focus-visible:ring-emerald-500 dark:bg-emerald-500",
outline:
"border border-emerald-300 bg-emerald-50 text-emerald-700 shadow-sm focus-visible:ring-emerald-400 dark:border-emerald-800/70 dark:bg-emerald-950/40 dark:text-emerald-300",
ghost:
"bg-emerald-50 text-emerald-700 focus-visible:ring-emerald-400 dark:bg-emerald-950/40 dark:text-emerald-300",
danger:
"bg-emerald-600 text-white shadow-sm shadow-emerald-600/25 focus-visible:ring-emerald-500 dark:bg-emerald-500",
};
function LoadingButton({
idleLabel,
loadingLabel,
successLabel,
variant = "solid",
size = "md",
fullWidth = false,
onRun,
}: {
idleLabel: string;
loadingLabel: string;
successLabel: string;
variant?: Variant;
size?: Size;
fullWidth?: boolean;
onRun?: () => Promise<void> | void;
}) {
const [status, setStatus] = useState<Status>("idle");
const timers = useRef<number[]>([]);
useEffect(() => {
const t = timers.current;
return () => {
t.forEach((id) => window.clearTimeout(id));
};
}, []);
async function handleClick() {
if (status !== "idle") return;
setStatus("loading");
if (onRun) await onRun();
else await new Promise((r) => timers.current.push(window.setTimeout(r, 1600)));
setStatus("success");
timers.current.push(window.setTimeout(() => setStatus("idle"), 1800));
}
const busy = status === "loading";
const done = status === "success";
return (
<button
type="button"
onClick={handleClick}
disabled={busy}
aria-busy={busy}
aria-live="polite"
className={[
"relative inline-flex select-none items-center justify-center overflow-hidden font-semibold",
"outline-none transition-[background-color,border-color,color,transform,box-shadow] duration-150",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
"active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-95",
"motion-reduce:transition-none motion-reduce:active:scale-100",
sizeMap[size],
done ? successMap[variant] : variantMap[variant],
fullWidth ? "w-full" : "",
].join(" ")}
>
<span
className={[
"inline-flex items-center",
size === "sm" ? "gap-1.5" : size === "lg" ? "gap-2.5" : "gap-2",
].join(" ")}
>
<span className="relative inline-flex" style={{ width: 0 }} aria-hidden="true">
<span
className={[
"btnld-icon-slot absolute right-0 top-1/2 -translate-y-1/2",
busy || done ? "btnld-icon-in" : "btnld-icon-out",
].join(" ")}
>
{done ? (
<Check className={`${iconSizeMap[size]} text-current`} />
) : (
<Spinner className={`${iconSizeMap[size]} text-current`} />
)}
</span>
</span>
<span
className={[
"btnld-label transition-[margin] duration-200 motion-reduce:transition-none",
busy || done
? size === "sm"
? "ml-[22px]"
: size === "lg"
? "ml-[30px]"
: "ml-[26px]"
: "ml-0",
].join(" ")}
>
{busy ? loadingLabel : done ? successLabel : idleLabel}
</span>
</span>
</button>
);
}
export default function BtnLoading() {
const prefersReduced = useReducedMotion();
return (
<section className="relative w-full bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-10">
<style>{`
@keyframes btnld-spin-kf {
to { transform: rotate(360deg); }
}
@keyframes btnld-check-kf {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}
@keyframes btnld-pop-kf {
0% { transform: translateY(-50%) scale(0.4); opacity: 0; }
60% { transform: translateY(-50%) scale(1.12); opacity: 1; }
100% { transform: translateY(-50%) scale(1); opacity: 1; }
}
.btnld-spin {
transform-origin: center;
animation: btnld-spin-kf 0.7s linear infinite;
}
.btnld-check {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: btnld-check-kf 0.4s ease-out 0.05s forwards;
}
.btnld-icon-slot { transition: opacity 0.18s ease, transform 0.18s ease; }
.btnld-icon-out { opacity: 0; transform: translateY(-50%) scale(0.5); }
.btnld-icon-in { animation: btnld-pop-kf 0.28s cubic-bezier(0.34,1.56,0.64,1) both; }
@media (prefers-reduced-motion: reduce) {
.btnld-spin { animation: btnld-spin-kf 1.1s linear infinite; }
.btnld-check { animation: none; stroke-dashoffset: 0; }
.btnld-icon-in { animation: none; opacity: 1; transform: translateY(-50%) scale(1); }
}
`}</style>
<div className="mx-auto max-w-3xl">
<header className="mb-12 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Async actions
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
Loading buttons
</h2>
<p className="mx-auto mt-3 max-w-md text-sm text-slate-500 dark:text-slate-400">
Click any button: it locks, spins while the request runs, then
confirms success before resetting. Fully keyboard accessible.
{prefersReduced ? " Reduced-motion mode is active." : ""}
</p>
</header>
<div className="grid gap-10">
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-400">
Variants
</p>
<div className="flex flex-wrap items-center gap-3">
<LoadingButton
idleLabel="Deploy to production"
loadingLabel="Deploying"
successLabel="Deployed"
variant="solid"
/>
<LoadingButton
idleLabel="Save changes"
loadingLabel="Saving"
successLabel="Saved"
variant="outline"
/>
<LoadingButton
idleLabel="Sync now"
loadingLabel="Syncing"
successLabel="Synced"
variant="ghost"
/>
<LoadingButton
idleLabel="Delete workspace"
loadingLabel="Deleting"
successLabel="Deleted"
variant="danger"
/>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-400">
Sizes
</p>
<div className="flex flex-wrap items-center gap-3">
<LoadingButton
idleLabel="Subscribe"
loadingLabel="Processing"
successLabel="You're in"
variant="solid"
size="sm"
/>
<LoadingButton
idleLabel="Subscribe"
loadingLabel="Processing"
successLabel="You're in"
variant="solid"
size="md"
/>
<LoadingButton
idleLabel="Subscribe"
loadingLabel="Processing"
successLabel="You're in"
variant="solid"
size="lg"
/>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-400">
Full width
</p>
<div className="mx-auto max-w-sm">
<LoadingButton
idleLabel="Pay $49.00"
loadingLabel="Charging card"
successLabel="Payment complete"
variant="solid"
size="lg"
fullWidth
/>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

