Spinner
Loaders & Skeletons

Spinner

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.

Install

npx shadcn@latest add @paragon/spinner

spinner.tsx

"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>
  );
}