Skip to content

Arc, dots and pixel-grid spinners drawn on the icon grid, so they swap for icons.

Loading & progressno dependencies

01Preview

Activity

  • Building
    stealth-webBuilding · main · 4f2a91c
    0:42
  • MO
    Maya OkaforMaya is typingtyping a reply
    now
  • Indexing
    Indexing workspace2,418 files · search works on what’s done
    68%

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.

03Usage

import { Spinner } from "@/components/ui/spinner";

<Spinner />

// In place of a button's icon: the button already says it's busy.
<button aria-busy>
  <Spinner label="" size="sm" />
  Saving…
</button>

// Someone typing, or a background index.
<Spinner variant="dots" label="Maya is typing" />
<Spinner variant="grid" delay={150} />

04Source

import { cn } from "@/lib/cn";

// Three spinners drawn on the icon grid (16 units, 1.5 stroke), so a spinner can
// stand in for an icon at the same size without anything around it moving.
// Everything is CSS keyframes: a spinner must keep turning while the main thread
// is busy, which is exactly when it is on screen.
const CSS = `
@keyframes stealth-spinner-in { from { opacity: 0; transform: scale(0.8); } }
@keyframes stealth-spinner-turn { to { transform: rotate(360deg); } }
@keyframes stealth-spinner-arc {
  0% { stroke-dasharray: 8 92; stroke-dashoffset: 0; }
  50% { stroke-dasharray: 46 54; stroke-dashoffset: -22; }
  100% { stroke-dasharray: 8 92; stroke-dashoffset: -100; }
}
@keyframes stealth-spinner-dot {
  0%, 64%, 100% { transform: translateY(0); opacity: 0.32; }
  30% { transform: translateY(-2.5px); opacity: 1; }
}
@keyframes stealth-spinner-cell {
  0%, 70%, 100% { transform: scale(0.78); opacity: 0.18; }
  30% { transform: scale(1); opacity: 1; }
}
@keyframes stealth-spinner-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

.stealth-spinner { animation: stealth-spinner-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay: var(--spinner-delay, 0ms); }
.stealth-spinner [data-part="turn"] { animation: stealth-spinner-turn 1.1s linear infinite; }
.stealth-spinner [data-part="arc"] { animation: stealth-spinner-arc 1.5s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
.stealth-spinner [data-part="dot"] { transform-box: fill-box; animation: stealth-spinner-dot 1.1s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
.stealth-spinner [data-part="cell"] { transform-box: fill-box; transform-origin: center; animation: stealth-spinner-cell 1.2s cubic-bezier(0.45, 0, 0.55, 1) infinite; }

/* Reduced motion: nothing travels or turns. The whole mark breathes slowly instead,
   so "still working" is still said. Specific enough to outrank a global kill switch. */
@media (prefers-reduced-motion: reduce) {
  .stealth-spinner [data-part] { animation: none !important; }
  .stealth-spinner [data-part="arc"] { stroke-dasharray: 30 70; }
  .stealth-spinner [data-part="dot"] { opacity: 0.8; }
  .stealth-spinner [data-part="cell"] { opacity: 0.55; transform: scale(0.9); }
  .stealth-spinner > svg { animation: stealth-spinner-breathe 2.4s ease-in-out infinite !important; }
}
`;

const SIZES = { sm: 14, md: 16, lg: 20 } as const;

export type SpinnerProps = Omit<React.ComponentProps<"span">, "children"> & {
  variant?: "ring" | "dots" | "grid";
  /** 14, 16 or 20px, the same steps as icons. A number sets pixels directly. */
  size?: keyof typeof SIZES | number;
  /** Read by screen readers. Pass an empty string when the parent already says it is busy. */
  label?: string;
  /** Milliseconds to wait before fading in, so fast work never flashes a spinner. */
  delay?: number;
};

export function Spinner({ variant = "ring", size = "md", label = "Loading", delay = 0, className, style, ...rest }: SpinnerProps) {
  const px = typeof size === "number" ? size : SIZES[size];
  const decorative = !label;

  return (
    <span
      role={decorative ? undefined : "status"}
      aria-hidden={decorative || undefined}
      data-variant={variant}
      data-size={typeof size === "number" ? undefined : size}
      className={cn("stealth-spinner relative inline-grid shrink-0 place-items-center align-middle", className)}
      style={{ width: px, height: px, ...(delay ? { "--spinner-delay": `${delay}ms` } : null), ...style } as React.CSSProperties}
      {...rest}
    >
      <style href="stealth-spinner" precedence="default">
        {CSS}
      </style>
      <svg width={px} height={px} viewBox="0 0 16 16" fill="none" aria-hidden focusable="false" className="overflow-visible">
        {variant === "ring" && <Ring />}
        {variant === "dots" && <Dots />}
        {variant === "grid" && <Grid />}
      </svg>
      {!decorative && <span className="sr-only">{label}</span>}
    </span>
  );
}

function Ring() {
  return (
    <>
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" opacity="0.18" />
      {/* The turn and the breathing arc run on different periods, so the loop never looks mechanical. */}
      <g data-part="turn" style={{ transformOrigin: "8px 8px" }}>
        <circle
          data-part="arc"
          cx="8"
          cy="8"
          r="6"
          pathLength={100}
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeDasharray="8 92"
          transform="rotate(-90 8 8)"
        />
      </g>
    </>
  );
}

function Dots() {
  return (
    <>
      {[3, 8, 13].map((cx, i) => (
        <circle key={cx} data-part="dot" cx={cx} cy="8" r="1.6" fill="currentColor" style={{ animationDelay: `${i * 0.14}s` }} />
      ))}
    </>
  );
}

function Grid() {
  const at = [1.4, 6.2, 11];
  return (
    <>
      {at.flatMap((y, row) =>
        at.map((x, col) => (
          // A diagonal wave: each cell lights one step after the cell above-left of it.
          <rect key={`${row}${col}`} data-part="cell" x={x} y={y} width="3.6" height="3.6" rx="0.9" fill="currentColor" style={{ animationDelay: `${(row + col) * 0.1}s` }} />
        )),
      )}
    </>
  );
}

05Props

PropTypeDefaultDescription
variant"ring" | "dots" | "grid""ring"A breathing arc for actions, a dot wave for someone typing, a pixel grid for background work.
size"sm" | "md" | "lg" | number"md"14, 16 or 20px, the icon sizes. A number sets pixels.
labelstring"Loading"Announced as a status. An empty string makes it decorative, for when the parent already has aria-busy.
delaynumber0Milliseconds before it fades in. Use 150 when the work might finish first.

06Notes

Behavior

  • Drawn on the same 16-unit grid as the icon set, so a spinner can take an icon's slot without the control changing width or height.
  • Takes currentColor: it is as quiet or as loud as the text beside it.
  • delay fades it in only after that many milliseconds, so fast work never flashes a spinner and needs no timer in your code.
  • Pure CSS with no state, so it keeps turning while the main thread is busy, which is when it is on screen.

Motion

  • Ring: the arc turns once every 1.1s (linear) while its length breathes between 8% and 46% every 1.5s; the two periods never line up, so the loop doesn't look mechanical.
  • Dots rise 2.5 units in a 1.1s wave, 140ms apart. The grid lights in a diagonal wave, 100ms per step, scaling from 0.78.
  • Every spinner enters with a 180ms fade from 0.8 scale on the expo ease-out.
  • Reduced motion stops all turning and travel; the mark holds still and breathes its opacity over 2.4s, so it still reads as working.

Accessibility

  • role="status" with a visually hidden label, so screen readers hear what is loading rather than an unnamed graphic.
  • label="" hides it from assistive tech entirely; put aria-busy and the words on the parent instead.
  • The SVG is always aria-hidden and unfocusable.