Loaders & Skeletons

Spinner

SVG arc spinner at 600ms per revolution with size variants, an aria label, and a three-dot variant with 120ms phase offsets.

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;

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