SVG arc spinner at 600ms per revolution with size and speed options and a three-dot variant on 120ms phase offsets; pauses offscreen, opacity pulse under reduced motion.
npx shadcn@latest add @paragon/spinner"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const sizeMap = { sm: 14, default: 18, lg: 24 } as const;
/** Base periods at speed 1. The arc stays ≤700ms/rev — fast reads as fast. */
const ARC_MS = 600;
const DOTS_MS = 900;
const DOT_STAGGER_MS = 120;
/** Pauses the loop while scrolled offscreen or while the tab is hidden. */
function usePlayState() {
const ref = React.useRef<HTMLSpanElement>(null);
const [playing, setPlaying] = React.useState(true);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
let inView = true;
let visible = !document.hidden;
const update = () => setPlaying(inView && visible);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(([entry]) => {
if (entry) {
inView = entry.isIntersecting;
update();
}
});
observer?.observe(el);
const onVisibility = () => {
visible = !document.hidden;
update();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
observer?.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return { ref, playing };
}
export interface SpinnerProps extends React.ComponentProps<"span"> {
size?: keyof typeof sizeMap;
/** Announced to assistive tech. */
label?: string;
variant?: "arc" | "dots";
/** Rate multiplier: 1 = 600ms/rev arc. Clamped to 0.25–4. */
speed?: number;
}
/**
* Loading spinner. The arc spins at 600ms/rev — fast reads as fast — on the
* one easing allowed for constant motion: linear. The dots variant pulses
* three dots with a 120ms phase offset, phase-shifted by negative delays so
* the wave is already running on first paint. Both loops pause offscreen and
* in hidden tabs; reduced motion swaps the rotation for a gentle opacity
* pulse.
*/
const SPINNER_KEYFRAMES = `
@keyframes pg-spinner-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pg-spinner-dot {
0%, 60%, 100% { opacity: 0.25; }
30% { opacity: 1; }
}
@keyframes pg-spinner-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
[data-spinner] {
animation: pg-spinner-spin var(--pg-spinner-duration, ${ARC_MS}ms) linear infinite;
}
[data-spinner-dot] {
animation: pg-spinner-dot var(--pg-spinner-duration, ${DOTS_MS}ms) var(--ease-in-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-spinner] {
animation: pg-spinner-fade 1.6s var(--ease-in-out) infinite;
}
[data-spinner-dot] { animation-duration: 2s; }
}
`;
export function Spinner({
size = "default",
label = "Loading",
variant = "arc",
speed = 1,
className,
style,
...props
}: SpinnerProps) {
const px = sizeMap[size];
const { ref, playing } = usePlayState();
const rate = Math.min(Math.max(speed, 0.25), 4);
if (variant === "dots") {
const dot = Math.max(3, Math.round(px / 4.5));
const cycle = DOTS_MS / rate;
return (
<span
ref={ref}
role="status"
data-slot="spinner"
className={cn("inline-flex items-center", className)}
style={
{
gap: dot * 0.9,
"--pg-spinner-duration": `${cycle}ms`,
...style,
} as React.CSSProperties
}
{...props}
>
<style href="paragon-spinner" precedence="paragon">
{SPINNER_KEYFRAMES}
</style>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((i) => (
<span
key={i}
data-spinner-dot=""
aria-hidden
className="rounded-full bg-current"
style={{
width: dot,
height: dot,
// Negative delays keep the stagger but start mid-cycle, so no
// dot flashes at full opacity while waiting for its turn.
animationDelay: `${(i * DOT_STAGGER_MS - DOTS_MS) / rate}ms`,
animationPlayState: playing ? "running" : "paused",
}}
/>
))}
</span>
);
}
return (
<span
ref={ref}
role="status"
data-slot="spinner"
className={cn("inline-flex", className)}
style={
{ "--pg-spinner-duration": `${ARC_MS / rate}ms`, ...style } as React.CSSProperties
}
{...props}
>
<style href="paragon-spinner" precedence="paragon">
{SPINNER_KEYFRAMES}
</style>
<span className="sr-only">{label}</span>
<svg
data-spinner=""
aria-hidden
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
style={{ animationPlayState: playing ? "running" : "paused" }}
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2.5"
opacity="0.2"
/>
<path
d="M22 12a10 10 0 0 0-10-10"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
</span>
);
}