Web InnoventixFreeCode

Loading Button

Original · free

A button that shows a spinner and disables itself while loading.

byWeb InnoventixReact + Tailwind
btnloadingbuttons
Open

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
btn-loading.tsx
"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

motion

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 quote

Similar components

Browse all →