Spinner Set Loader
Original · freeset of circular spinners in sizes
byWeb InnoventixReact + Tailwind
loadspinnersetloaders
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-spinner-set.jsonload-spinner-set.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
type RefObject,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type SpinnerVariant = "ring" | "dual" | "orbit" | "arc" | "ticks" | "pulse";
type SpinnerStyle = CSSProperties & {
"--sz": string;
"--bw": string;
"--accent": string;
"--spd": string;
"--play": string;
};
const SIZES = [
{ key: "xs", label: "XS", px: 20 },
{ key: "sm", label: "SM", px: 32 },
{ key: "md", label: "MD", px: 48 },
{ key: "lg", label: "LG", px: 72 },
{ key: "xl", label: "XL", px: 104 },
] as const;
const ACCENTS = [
{ key: "indigo", label: "Indigo", hex: "#6366f1" },
{ key: "violet", label: "Violet", hex: "#8b5cf6" },
{ key: "sky", label: "Sky", hex: "#0ea5e9" },
{ key: "emerald", label: "Emerald", hex: "#10b981" },
{ key: "amber", label: "Amber", hex: "#f59e0b" },
{ key: "rose", label: "Rose", hex: "#f43f5e" },
] as const;
const VARIANTS: { id: SpinnerVariant; name: string; desc: string }[] = [
{ id: "ring", name: "Ring", desc: "Single sweeping arc" },
{ id: "dual", name: "Dual ring", desc: "Counter-rotating arcs" },
{ id: "orbit", name: "Orbit", desc: "Satellite on a track" },
{ id: "arc", name: "Gradient arc", desc: "Conic sweep with cap" },
{ id: "ticks", name: "Ticks", desc: "Twelve fading bars" },
{ id: "pulse", name: "Pulse", desc: "Expanding echo rings" },
];
const bwFor = (px: number) => Math.max(2, Math.round(px / 11));
const FOCUS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";
function cx(...parts: (string | false | null | undefined)[]) {
return parts.filter(Boolean).join(" ");
}
function onRoveKey(
e: KeyboardEvent<HTMLButtonElement>,
index: number,
count: number,
set: (n: number) => void,
refs: RefObject<(HTMLButtonElement | null)[]>,
) {
let next = index;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = (index + 1) % count;
break;
case "ArrowLeft":
case "ArrowUp":
next = (index - 1 + count) % count;
break;
case "Home":
next = 0;
break;
case "End":
next = count - 1;
break;
default:
return;
}
e.preventDefault();
set(next);
refs.current?.[next]?.focus();
}
function Spinner({
variant,
px,
accent,
spd,
paused,
name,
decorative = false,
}: {
variant: SpinnerVariant;
px: number;
accent: string;
spd: number;
paused: boolean;
name: string;
decorative?: boolean;
}) {
const bw = bwFor(px);
const style: SpinnerStyle = {
width: `${px}px`,
height: `${px}px`,
"--sz": `${px}px`,
"--bw": `${bw}px`,
"--accent": accent,
"--spd": `${spd}s`,
"--play": paused ? "paused" : "running",
};
let body: ReactNode = null;
switch (variant) {
case "ring":
body = <span className="lss-anim lss-ring" />;
break;
case "dual":
body = (
<>
<span className="lss-track" />
<span className="lss-anim lss-dual-a" />
<span className="lss-anim lss-dual-b" />
</>
);
break;
case "orbit":
body = (
<>
<span className="lss-track" />
<span className="lss-anim lss-orbit">
<span className="lss-orbit-dot" />
</span>
</>
);
break;
case "arc":
body = (
<span className="lss-anim lss-arc">
<span className="lss-arc-cap" />
</span>
);
break;
case "ticks":
body = (
<>
{Array.from({ length: 12 }).map((_, i) => (
<span
key={i}
className="lss-anim lss-tick"
style={{
transform: `rotate(${i * 30}deg)`,
animationDelay: `${(-spd * 1.1 * i) / 12}s`,
}}
/>
))}
</>
);
break;
case "pulse":
body = (
<>
<span className="lss-track" />
<span className="lss-anim lss-pulse" />
<span
className="lss-anim lss-pulse"
style={{ animationDelay: `${spd / 2}s` }}
/>
</>
);
break;
}
return (
<div
role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : `${name} spinner, loading`}
className="relative inline-block text-slate-300 dark:text-slate-700"
style={style}
>
{body}
</div>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6 4.5v11a1 1 0 0 0 1.54.84l8.5-5.5a1 1 0 0 0 0-1.68l-8.5-5.5A1 1 0 0 0 6 4.5Z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<rect x="5" y="4" width="4" height="12" rx="1.2" />
<rect x="11" y="4" width="4" height="12" rx="1.2" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" className={className} fill="none" aria-hidden="true">
<path
d="m4.5 10.5 3.2 3.2 7.8-8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function LoadSpinnerSet() {
const reduce = useReducedMotion();
const [sizeIndex, setSizeIndex] = useState(2);
const [accentIndex, setAccentIndex] = useState(0);
const [spd, setSpd] = useState(0.9);
const [paused, setPaused] = useState(false);
const [previewVariant, setPreviewVariant] = useState<SpinnerVariant>("ring");
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const sizeBtns = useRef<(HTMLButtonElement | null)[]>([]);
const accentBtns = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const titleId = useId();
const sizeLabelId = useId();
const accentLabelId = useId();
const speedId = useId();
const previewId = useId();
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const size = SIZES[sizeIndex];
const accent = ACCENTS[accentIndex];
const effectivePaused = paused || !!reduce;
const dur = reduce ? 0 : 0.24;
const previewName =
VARIANTS.find((v) => v.id === previewVariant)?.name ?? "Spinner";
const runLoad = () => {
if (timer.current) clearTimeout(timer.current);
setLoaded(false);
setLoading(true);
timer.current = setTimeout(() => {
setLoading(false);
setLoaded(true);
}, 1800);
};
return (
<section
aria-labelledby={titleId}
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-50 px-4 py-16 text-slate-900 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100 sm:px-6 sm:py-24"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-28 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-32 right-0 h-72 w-72 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-700/15"
/>
<div className="relative mx-auto max-w-5xl">
<header className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Loaders / Circular spinners
</span>
<h2
id={titleId}
className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl"
>
The circular spinner set
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
Six pure-CSS loaders that scale cleanly from a tiny inline glyph to a
full-screen overlay. Choose a size, accent, and rotation duration —
every variant shares one accessible, reduced-motion-aware core.
</p>
</header>
{/* Toolbar */}
<div className="mt-8 rounded-2xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/50 sm:p-6">
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-end">
{/* Size */}
<div>
<span
id={sizeLabelId}
className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Size
</span>
<div
role="radiogroup"
aria-labelledby={sizeLabelId}
className="inline-flex rounded-xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950/60"
>
{SIZES.map((s, i) => (
<button
key={s.key}
ref={(el) => {
sizeBtns.current[i] = el;
}}
type="button"
role="radio"
aria-checked={i === sizeIndex}
tabIndex={i === sizeIndex ? 0 : -1}
onClick={() => setSizeIndex(i)}
onKeyDown={(e) =>
onRoveKey(e, i, SIZES.length, setSizeIndex, sizeBtns)
}
className={cx(
"min-w-[3rem] rounded-lg px-3 py-1.5 text-sm font-semibold transition",
FOCUS,
i === sizeIndex
? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
: "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white",
)}
>
{s.label}
</button>
))}
</div>
</div>
{/* Accent */}
<div>
<span
id={accentLabelId}
className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Accent
</span>
<div
role="radiogroup"
aria-labelledby={accentLabelId}
className="flex items-center gap-2"
>
{ACCENTS.map((a, i) => (
<button
key={a.key}
ref={(el) => {
accentBtns.current[i] = el;
}}
type="button"
role="radio"
aria-checked={i === accentIndex}
tabIndex={i === accentIndex ? 0 : -1}
onClick={() => setAccentIndex(i)}
onKeyDown={(e) =>
onRoveKey(e, i, ACCENTS.length, setAccentIndex, accentBtns)
}
style={{ backgroundColor: a.hex }}
className={cx(
"grid h-8 w-8 place-items-center rounded-full transition",
FOCUS,
i === accentIndex &&
"outline outline-2 outline-offset-2 outline-slate-900 dark:outline-white",
)}
>
{i === accentIndex && (
<CheckIcon className="h-4 w-4 text-white drop-shadow" />
)}
<span className="sr-only">{a.label}</span>
</button>
))}
</div>
</div>
{/* Duration */}
<div className="min-w-[13rem] flex-1">
<div className="mb-2 flex items-center justify-between">
<label
htmlFor={speedId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Duration
</label>
<span className="tabular-nums text-xs font-semibold text-slate-700 dark:text-slate-200">
{spd.toFixed(1)}s / turn
</span>
</div>
<input
id={speedId}
type="range"
min={0.4}
max={2}
step={0.1}
value={spd}
onChange={(e) => setSpd(parseFloat(e.target.value))}
aria-valuetext={`${spd.toFixed(1)} seconds per turn`}
className={cx(
"w-full cursor-pointer rounded-full accent-indigo-500 dark:accent-indigo-400",
FOCUS,
)}
/>
</div>
{/* Pause */}
<div>
<button
type="button"
onClick={() => setPaused((p) => !p)}
aria-pressed={paused}
className={cx(
"inline-flex items-center gap-2 rounded-xl border px-4 py-2 text-sm font-semibold transition",
FOCUS,
paused
? "border-slate-300 bg-white text-slate-900 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:hover:bg-slate-800"
: "border-transparent bg-slate-900 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200",
)}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "Play" : "Pause"}
</button>
</div>
</div>
{reduce && (
<p className="mt-4 text-xs text-slate-500 dark:text-slate-400">
Motion is paused to respect your system’s reduced-motion setting.
</p>
)}
</div>
{/* Gallery */}
<div className="mt-10">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
The set
</h3>
<ul
role="list"
className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3"
>
{VARIANTS.map((v) => (
<li
key={v.id}
className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur transition hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/40 dark:hover:border-slate-700"
>
<div className="grid h-32 w-full place-items-center">
<Spinner
variant={v.id}
px={size.px}
accent={accent.hex}
spd={spd}
paused={effectivePaused}
name={v.name}
/>
</div>
<div className="text-center">
<div className="text-sm font-semibold text-slate-900 dark:text-white">
{v.name}
</div>
<div className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{v.desc}
</div>
</div>
</li>
))}
</ul>
</div>
{/* Sizes strip + live demo */}
<div className="mt-6 grid gap-6 lg:grid-cols-5">
{/* Sizes strip */}
<div className="rounded-2xl border border-slate-200 bg-white/70 p-6 backdrop-blur dark:border-slate-800 dark:bg-slate-900/40 lg:col-span-3">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
One spinner, every size
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
The same component, scaled from 20 to 104 px.
</p>
</div>
<div className="flex items-center gap-2">
<label
htmlFor={previewId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Style
</label>
<select
id={previewId}
value={previewVariant}
onChange={(e) =>
setPreviewVariant(e.target.value as SpinnerVariant)
}
className={cx(
"rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-800 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100",
FOCUS,
)}
>
{VARIANTS.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<div className="flex min-w-max items-end gap-3">
{SIZES.map((s, i) => (
<div
key={s.key}
aria-current={i === sizeIndex ? "true" : undefined}
className={cx(
"flex w-28 flex-col items-center gap-2 rounded-xl border p-3 transition",
i === sizeIndex
? "border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800/50"
: "border-transparent",
)}
>
<div className="grid h-28 w-full place-items-center">
<Spinner
variant={previewVariant}
px={s.px}
accent={accent.hex}
spd={spd}
paused={effectivePaused}
name={previewName}
/>
</div>
<div className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{s.label}
</div>
<div className="text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
{s.px}px
</div>
</div>
))}
</div>
</div>
</div>
{/* Live demo */}
<div className="flex flex-col rounded-2xl border border-slate-200 bg-white/70 p-6 backdrop-blur dark:border-slate-800 dark:bg-slate-900/40 lg:col-span-2">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
Accessible in practice
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
A polite live region announces each state change to screen readers.
</p>
<button
type="button"
onClick={runLoad}
disabled={loading}
aria-busy={loading}
className={cx(
"mt-4 inline-flex w-fit items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400",
FOCUS,
)}
>
{loading ? "Working…" : loaded ? "Run again" : "Generate report"}
</button>
<div className="mt-5 grid min-h-[6rem] flex-1 place-items-center rounded-xl bg-slate-50 px-4 dark:bg-slate-950/50">
<AnimatePresence mode="wait" initial={false}>
{loading ? (
<motion.div
key="loading"
role="status"
aria-live="polite"
className="flex items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: dur }}
>
<Spinner
variant={previewVariant}
px={28}
accent={accent.hex}
spd={spd}
paused={effectivePaused}
name={previewName}
decorative
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
Generating your report…
</span>
</motion.div>
) : loaded ? (
<motion.div
key="done"
role="status"
aria-live="polite"
className="flex items-center gap-2 text-sm font-semibold text-emerald-600 dark:text-emerald-400"
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: dur }}
>
<CheckIcon className="h-4 w-4" />
Report ready
</motion.div>
) : (
<motion.p
key="idle"
className="text-sm text-slate-500 dark:text-slate-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: dur }}
>
Nothing running yet.
</motion.p>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
<style>{`
@keyframes lss-spin { to { transform: rotate(360deg); } }
@keyframes lss-tickfade { 0% { opacity: 1; } 100% { opacity: 0.15; } }
@keyframes lss-pulsegrow {
0% { transform: scale(0.15); opacity: 1; }
100% { transform: scale(1); opacity: 0; }
}
.lss-track,
.lss-ring,
.lss-dual-a,
.lss-dual-b,
.lss-orbit,
.lss-arc,
.lss-tick,
.lss-pulse { box-sizing: border-box; }
.lss-track {
position: absolute; inset: 0; border-radius: 9999px;
border: var(--bw) solid currentColor; opacity: 0.35;
}
.lss-ring {
position: absolute; inset: 0; border-radius: 9999px;
border: var(--bw) solid currentColor;
border-top-color: var(--accent);
animation: lss-spin var(--spd) linear infinite;
animation-play-state: var(--play);
}
.lss-dual-a {
position: absolute; inset: 0; border-radius: 9999px;
border: var(--bw) solid transparent;
border-top-color: var(--accent);
border-right-color: var(--accent);
animation: lss-spin var(--spd) cubic-bezier(0.6, 0.15, 0.4, 0.85) infinite;
animation-play-state: var(--play);
}
.lss-dual-b {
position: absolute; inset: calc(var(--bw) * 1.7); border-radius: 9999px;
border: var(--bw) solid transparent;
border-bottom-color: var(--accent);
opacity: 0.6;
animation: lss-spin calc(var(--spd) * 1.5) linear infinite reverse;
animation-play-state: var(--play);
}
.lss-orbit {
position: absolute; inset: 0;
animation: lss-spin var(--spd) linear infinite;
animation-play-state: var(--play);
}
.lss-orbit-dot {
position: absolute; top: calc(var(--bw) * -0.45); left: 50%;
width: calc(var(--bw) * 1.9); height: calc(var(--bw) * 1.9);
margin-left: calc(var(--bw) * -0.95);
border-radius: 9999px; background: var(--accent);
}
.lss-arc {
position: absolute; inset: 0; border-radius: 9999px;
background: conic-gradient(from 0deg, transparent 0%, var(--accent) 100%);
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - var(--bw)), #000 calc(100% - var(--bw)));
mask: radial-gradient(farthest-side, #0000 calc(100% - var(--bw)), #000 calc(100% - var(--bw)));
animation: lss-spin var(--spd) linear infinite;
animation-play-state: var(--play);
}
.lss-arc-cap {
position: absolute; top: 0; left: 50%;
width: var(--bw); height: var(--bw);
margin-left: calc(var(--bw) / -2);
border-radius: 9999px; background: var(--accent);
}
.lss-tick {
position: absolute; top: 0; left: 50%;
width: var(--bw); height: calc(var(--sz) * 0.26);
margin-left: calc(var(--bw) / -2);
border-radius: 9999px; background: var(--accent);
transform-origin: center calc(var(--sz) / 2);
animation: lss-tickfade calc(var(--spd) * 1.1) linear infinite;
animation-play-state: var(--play);
}
.lss-pulse {
position: absolute; inset: 0; border-radius: 9999px;
border: var(--bw) solid var(--accent);
animation: lss-pulsegrow var(--spd) cubic-bezier(0.2, 0.6, 0.4, 1) infinite;
animation-play-state: var(--play);
}
@media (prefers-reduced-motion: reduce) {
.lss-anim { animation-play-state: paused !important; }
}
`}</style>
</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 quoteSimilar components
Browse all →
Dots Loader
Originalbouncing dots loader

Bars Loader
Originalequalizer bars loader

Ring Loader
Originalring spinner loader

Pulse Loader
Originalpulsing circle loader

Orbit Loader
Originalorbiting dots loader

Wave Loader
Originalwave loader

Bounce Loader
Originalbouncing ball loader

Gradient Ring Loader
Originalgradient conic ring spinner

Dual Ring Loader
Originaldual counter-rotating rings

Square Loader
Originalmorphing square loader

Typing Loader
Originaltyping indicator loader

Page Overlay Loader
Originalfull-page loading overlay with progress

