SVG arc spinner at 600ms per revolution with size variants, an aria label, and a three-dot variant with 120ms phase offsets.
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;
export interface SpinnerProps extends React.ComponentProps<"span"> {
size?: keyof typeof sizeMap;
/** Announced to assistive tech. */
label?: string;
variant?: "arc" | "dots";
}
/**
* Loading spinner. The arc spins at 600ms/rev — fast reads as fast — on the
* one easing allowed for constant motion: linear. Reduced motion swaps the
* rotation for a gentle opacity pulse. The dots variant pulses three dots
* with a 120ms phase offset.
*/
const SPINNER_KEYFRAMES = `
@keyframes pg-spinner-dot {
0%, 60%, 100% { opacity: 0.25; }
30% { opacity: 1; }
}
[data-spinner-dot] {
animation: pg-spinner-dot 900ms var(--ease-in-out) infinite;
}
@keyframes pg-spinner-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pg-spinner-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
[data-spinner] {
animation: pg-spinner-spin 600ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-spinner-dot] { animation-duration: 1.8s; }
[data-spinner] {
animation: pg-spinner-fade 1.6s var(--ease-in-out) infinite;
}
}
`;
export function Spinner({
size = "default",
label = "Loading",
variant = "arc",
className,
...props
}: SpinnerProps) {
const px = sizeMap[size];
if (variant === "dots") {
const dot = Math.max(3, Math.round(px / 4.5));
return (
<span
role="status"
aria-label={label}
className={cn("inline-flex items-center", className)}
style={{ gap: dot * 0.9 }}
{...props}
>
<style href="paragon-spinner" precedence="paragon">{SPINNER_KEYFRAMES}</style>
{[0, 1, 2].map((i) => (
<span
key={i}
data-spinner-dot=""
aria-hidden
className="rounded-full bg-current"
style={{
width: dot,
height: dot,
animationDelay: `${i * 120}ms`,
}}
/>
))}
</span>
);
}
return (
<span
role="status"
aria-label={label}
className={cn("inline-flex", className)}
{...props}
>
<style href="paragon-spinner" precedence="paragon">{SPINNER_KEYFRAMES}</style>
<svg
data-spinner=""
aria-hidden
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
>
<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>
);
}