Web InnoventixFreeCode

Button Inline Loader

Original · free

inline button loading spinners

byWeb InnoventixReact + Tailwind
loadbuttoninlineloaders
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/load-button-inline.json
load-button-inline.tsx
"use client";

import { useEffect, useRef, useState, type ReactElement } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "idle" | "loading" | "success" | "error";
type Variant = "primary" | "contrast" | "outline" | "soft" | "sky" | "violet";
type SpinnerKind = "ring" | "dots" | "dual" | "bars" | "segment" | "orbit";

interface ButtonSpec {
  id: string;
  variant: Variant;
  spinner: SpinnerKind;
  spinnerName: string;
  icon: ReactElement;
  labels: Record<Status, string>;
}

function IconSave(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="M12 3v9" />
      <path d="m8.5 8.5 3.5 3.5 3.5-3.5" />
      <path d="M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" />
    </svg>
  );
}

function IconBolt(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="M13 2.5 4.5 13H11l-1 8.5L19.5 10H13l1-7.5Z" />
    </svg>
  );
}

function IconMail(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <rect x="3" y="5" width="18" height="14" rx="2" />
      <path d="m3.5 7 8.5 6 8.5-6" />
    </svg>
  );
}

function IconSync(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="M20 11a8 8 0 0 0-13.7-5" />
      <path d="M4 13a8 8 0 0 0 13.7 5" />
      <path d="M18 3v3.5h-3.5" />
      <path d="M6 21v-3.5h3.5" />
    </svg>
  );
}

function IconExport(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="M12 14V3" />
      <path d="m8.5 6.5 3.5-3.5 3.5 3.5" />
      <path d="M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" />
    </svg>
  );
}

function IconCard(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <rect x="3" y="5" width="18" height="14" rx="2" />
      <path d="M3 10h18" />
      <path d="M7 15h3" />
    </svg>
  );
}

function IconCheck(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="m5 12.5 4.4 4.4L19 7" />
    </svg>
  );
}

function IconAlert(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
      <path d="M10.3 4 3.4 16a2 2 0 0 0 1.7 3h13.8a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0Z" />
      <path d="M12 9.5v4" />
      <path d="M12 16.5h.01" />
    </svg>
  );
}

function RingSpinner(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="lbi-spin h-4 w-4">
      <circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M12 3.5a8.5 8.5 0 0 1 8.5 8.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

function OrbitSpinner(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="lbi-spin h-4 w-4">
      <circle cx="12" cy="12" r="8" stroke="currentColor" strokeOpacity="0.22" strokeWidth="2" />
      <circle cx="12" cy="4" r="2.3" fill="currentColor" />
    </svg>
  );
}

function DualSpinner(): ReactElement {
  return (
    <span className="relative inline-flex h-4 w-4">
      <svg viewBox="0 0 24 24" fill="none" className="lbi-spin absolute inset-0 h-4 w-4">
        <path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" />
      </svg>
      <svg viewBox="0 0 24 24" fill="none" className="lbi-spin-rev absolute inset-0 h-4 w-4">
        <path d="M12 21a9 9 0 0 1-9-9" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeOpacity="0.5" />
      </svg>
    </span>
  );
}

function DotsSpinner(): ReactElement {
  return (
    <span className="flex h-4 w-4 items-center justify-center gap-[3px]">
      {[0, 1, 2].map((i) => (
        <span key={i} className="lbi-bounce block h-1.5 w-1.5 rounded-full bg-current" style={{ animationDelay: `${i * 0.15}s` }} />
      ))}
    </span>
  );
}

function BarsSpinner(): ReactElement {
  return (
    <span className="flex h-4 w-4 items-center justify-center gap-[2px]">
      {[0, 1, 2, 3].map((i) => (
        <span key={i} className="lbi-bar block h-4 w-[2.5px] origin-center rounded-full bg-current" style={{ animationDelay: `${i * 0.12}s` }} />
      ))}
    </span>
  );
}

function SegmentSpinner(): ReactElement {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4">
      {Array.from({ length: 8 }).map((_, i) => (
        <rect
          key={i}
          x="11"
          y="2.5"
          width="2"
          height="5.5"
          rx="1"
          fill="currentColor"
          transform={`rotate(${i * 45} 12 12)`}
          className="lbi-fade"
          style={{ animationDelay: `${i * 0.1}s` }}
        />
      ))}
    </svg>
  );
}

function renderSpinner(kind: SpinnerKind): ReactElement {
  switch (kind) {
    case "ring":
      return <RingSpinner />;
    case "dots":
      return <DotsSpinner />;
    case "dual":
      return <DualSpinner />;
    case "bars":
      return <BarsSpinner />;
    case "segment":
      return <SegmentSpinner />;
    case "orbit":
      return <OrbitSpinner />;
  }
}

const BUTTONS: ButtonSpec[] = [
  {
    id: "save",
    variant: "primary",
    spinner: "ring",
    spinnerName: "Circular ring",
    icon: <IconSave />,
    labels: { idle: "Save changes", loading: "Saving…", success: "Saved", error: "Try again" },
  },
  {
    id: "deploy",
    variant: "contrast",
    spinner: "dots",
    spinnerName: "Bouncing dots",
    icon: <IconBolt />,
    labels: { idle: "Deploy to production", loading: "Deploying…", success: "Deployed", error: "Redeploy" },
  },
  {
    id: "invite",
    variant: "outline",
    spinner: "dual",
    spinnerName: "Dual arc",
    icon: <IconMail />,
    labels: { idle: "Send invite", loading: "Sending…", success: "Invite sent", error: "Resend" },
  },
  {
    id: "sync",
    variant: "soft",
    spinner: "segment",
    spinnerName: "Segment sweep",
    icon: <IconSync />,
    labels: { idle: "Sync workspace", loading: "Syncing…", success: "Up to date", error: "Retry sync" },
  },
  {
    id: "export",
    variant: "sky",
    spinner: "bars",
    spinnerName: "Equalizer bars",
    icon: <IconExport />,
    labels: { idle: "Export report", loading: "Exporting…", success: "Downloaded", error: "Re-export" },
  },
  {
    id: "pay",
    variant: "violet",
    spinner: "orbit",
    spinnerName: "Orbiting dot",
    icon: <IconCard />,
    labels: { idle: "Confirm payment", loading: "Processing…", success: "Payment confirmed", error: "Retry payment" },
  },
];

function buttonClasses(variant: Variant, status: Status): string {
  const base =
    "relative inline-flex w-full items-center justify-center gap-2.5 rounded-xl px-5 py-3 text-sm font-semibold shadow-sm transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-900";

  if (status === "success") return `${base} bg-emerald-600 text-white focus-visible:ring-emerald-500`;
  if (status === "error") return `${base} bg-rose-600 text-white focus-visible:ring-rose-500`;

  switch (variant) {
    case "primary":
      return `${base} bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500`;
    case "contrast":
      return `${base} bg-slate-900 text-white hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-slate-300`;
    case "outline":
      return `${base} border border-slate-300 bg-white text-slate-800 hover:bg-slate-50 focus-visible:ring-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`;
    case "soft":
      return `${base} bg-indigo-50 text-indigo-700 hover:bg-indigo-100 focus-visible:ring-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20`;
    case "sky":
      return `${base} bg-sky-600 text-white hover:bg-sky-500 focus-visible:ring-sky-500`;
    case "violet":
      return `${base} bg-violet-600 text-white hover:bg-violet-500 focus-visible:ring-violet-500`;
  }
}

function leadingFor(spec: ButtonSpec, status: Status): ReactElement {
  if (status === "loading") return renderSpinner(spec.spinner);
  if (status === "success") return <IconCheck />;
  if (status === "error") return <IconAlert />;
  return spec.icon;
}

export default function LoadButtonInline(): ReactElement {
  const reduced = useReducedMotion() ?? false;
  const [statuses, setStatuses] = useState<Partial<Record<string, Status>>>({});
  const [outcome, setOutcome] = useState<"success" | "error">("success");
  const [announce, setAnnounce] = useState("");
  const timers = useRef<Record<string, number>>({});

  useEffect(() => {
    const store = timers.current;
    return () => {
      Object.values(store).forEach((t) => window.clearTimeout(t));
    };
  }, []);

  const statusOf = (id: string): Status => statuses[id] ?? "idle";

  const start = (spec: ButtonSpec): void => {
    const { id } = spec;
    window.clearTimeout(timers.current[`${id}:settle`]);
    window.clearTimeout(timers.current[`${id}:reset`]);

    setStatuses((prev) => ({ ...prev, [id]: "loading" }));
    setAnnounce(spec.labels.loading);

    timers.current[`${id}:settle`] = window.setTimeout(() => {
      setStatuses((prev) => ({ ...prev, [id]: outcome }));
      setAnnounce(outcome === "success" ? spec.labels.success : `${spec.labels.error} — action failed`);

      timers.current[`${id}:reset`] = window.setTimeout(() => {
        setStatuses((prev) => ({ ...prev, [id]: "idle" }));
      }, 2000);
    }, 1500);
  };

  const resetAll = (): void => {
    Object.values(timers.current).forEach((t) => window.clearTimeout(t));
    timers.current = {};
    setStatuses({});
    setAnnounce("Every action was reset to its starting state.");
  };

  const dur = reduced ? 0 : 0.18;

  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes lbi-spin { to { transform: rotate(360deg); } }
        @keyframes lbi-spin-rev { to { transform: rotate(-360deg); } }
        @keyframes lbi-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
        @keyframes lbi-bar { 0%, 100% { transform: scaleY(.35); opacity: .55; } 50% { transform: scaleY(1); opacity: 1; } }
        @keyframes lbi-fade { 0% { opacity: 1; } 100% { opacity: .15; } }
        @keyframes lbi-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
        .lbi-spin { animation: lbi-spin .7s linear infinite; }
        .lbi-spin-rev { animation: lbi-spin-rev .9s linear infinite; }
        .lbi-bounce { animation: lbi-bounce 1s ease-in-out infinite; }
        .lbi-bar { animation: lbi-bar .9s ease-in-out infinite; }
        .lbi-fade { animation: lbi-fade .8s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .lbi-spin, .lbi-spin-rev, .lbi-bounce, .lbi-bar, .lbi-fade {
            animation: lbi-pulse 1.6s ease-in-out infinite !important;
          }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Loaders
            </span>
            <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
              Inline button spinners
            </h2>
            <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Six drop-in loading states for async actions. Each button locks while it works, swaps its
              icon for a spinner, then confirms the result &mdash; wired with{" "}
              <code className="rounded bg-slate-200 px-1 py-0.5 font-mono text-[0.72rem] text-slate-700 dark:bg-slate-800 dark:text-slate-200">
                aria-busy
              </code>{" "}
              and a polite live region so assistive tech keeps up.
            </p>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <fieldset className="min-w-0">
              <legend className="sr-only">Simulated result on click</legend>
              <div className="inline-flex rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-700 dark:bg-slate-900">
                {(["success", "error"] as const).map((opt) => (
                  <label key={opt} className="cursor-pointer">
                    <input
                      type="radio"
                      name="lbi-outcome"
                      value={opt}
                      checked={outcome === opt}
                      onChange={() => setOutcome(opt)}
                      className="peer sr-only"
                    />
                    <span className="block rounded-md px-3 py-1.5 text-xs font-semibold text-slate-500 transition-colors peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 dark:text-slate-400 dark:peer-checked:bg-white dark:peer-checked:text-slate-900">
                      {opt === "success" ? "Resolve OK" : "Resolve error"}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <button
              type="button"
              onClick={resetAll}
              className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              Reset all
            </button>
          </div>
        </div>

        <div className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {BUTTONS.map((spec) => {
            const status = statusOf(spec.id);
            return (
              <div
                key={spec.id}
                className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900"
              >
                <button
                  type="button"
                  onClick={() => start(spec)}
                  disabled={status !== "idle"}
                  aria-busy={status === "loading"}
                  className={buttonClasses(spec.variant, status)}
                >
                  <span aria-hidden="true" className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
                    <AnimatePresence mode="wait" initial={false}>
                      <motion.span
                        key={status}
                        className="absolute inset-0 flex items-center justify-center"
                        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -45 }}
                        animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, rotate: 0 }}
                        exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: 45 }}
                        transition={{ duration: dur, ease: "easeOut" }}
                      >
                        {leadingFor(spec, status)}
                      </motion.span>
                    </AnimatePresence>
                  </span>

                  <span className="relative inline-flex items-center">
                    <AnimatePresence mode="wait" initial={false}>
                      <motion.span
                        key={status}
                        className="whitespace-nowrap"
                        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6 }}
                        animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}
                        exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
                        transition={{ duration: dur, ease: "easeOut" }}
                      >
                        {spec.labels[status]}
                      </motion.span>
                    </AnimatePresence>
                  </span>
                </button>

                <div className="flex items-center justify-between px-1">
                  <span className="text-xs font-medium text-slate-500 dark:text-slate-400">{spec.spinnerName}</span>
                  <span className="rounded-full bg-slate-100 px-2 py-0.5 font-mono text-[10px] text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                    {spec.spinner}
                  </span>
                </div>
              </div>
            );
          })}
        </div>

        <p className="mt-8 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
          Keyboard: focus any button and press Enter or Space. Buttons stay disabled while loading to
          prevent double-submits, and the toggle above previews both the success and error return
          states. Motion respects{" "}
          <code className="rounded bg-slate-200 px-1 py-0.5 font-mono text-[0.72rem] text-slate-700 dark:bg-slate-800 dark:text-slate-200">
            prefers-reduced-motion
          </code>
          .
        </p>
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {announce}
      </p>
    </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 →