Spinner
betav0.1.0Indeterminate busy indicator: says something is happening when how much is left is unknown. Replaces MUI `CircularProgress`, which both products render in this form and only this form — 62 render sites, none determinate, hence no `value` prop. Decorative by default, as those 62 sites are: the surrounding control carries `aria-busy`. Pass `label` when the spinner is the only thing saying work is in flight, and it becomes an announced `role="status"` region. If the percentage is known, use `ProgressBar`.
Variants
Examples
<Spinner /><Spinner size="sm" tone="inherit" /><Spinner label="Loading agents" /><span className="flex items-center gap-2">
<Spinner size="sm" />
Loading agents...
</span>{/* The bar motif: the 32px ring drawn AROUND a 20px glyph, not on it. */}
<span className="relative inline-flex items-center justify-center">
<Spinner size="xl" />
<span className="absolute inset-0 flex items-center justify-center">
<Icon name="shield" size={20} />
</span>
</span>Props
| Prop | Type |
|---|---|
size | "sm" | "md" | "lg" | "xl" — 16 / 20 / 24 / 32px, default "md". "sm"/"md"/"lg" are the Icon vocabulary; "xl" deliberately exceeds it for the bar motif (a ring drawn around a 20px glyph) without extending Icon — see Spinner.rfc.md arbitration Q10. The remaining product loaders (40/60/80/200px) are still out of scope, §7. |
tone | "brand" | "inherit" — "brand" (default) paints --icon-highlight; "inherit" emits no colour class, so the host's currentColor reaches the glyph (MUI `color="inherit"`, 7 product sites). |
label | string, optional — announced busy message, rendered as an sr-only copy inside a role="status" region. Omit it when visible text already says the same thing: without it the spinner is aria-hidden, which is right for all 62 product sites. |
className | string — merged with the wrapper's own classes, not replacing them. |
ref | React.Ref<HTMLSpanElement> — forwarded to the wrapper span. |
Accessibility
Ratios come from `pnpm check:contrast`, computed from theme.css in both modes, never typed by hand. tone="brand" dark: #42caff on #070d18 = 10.30:1 ✅ (threshold 3:1, a graphical object under WCAG 1.4.11, not text). Light: #0015a8 on #f2f2f3 = 11.22:1 ✅. tone="inherit" has no declarable pair by design: it emits no colour class, so the pair belongs to the host (inside Button, it is Button's own label colour, already measured there). Decorative by default — aria-hidden, no role, per AGENTS.md's rule for a visual spinner. With `label`, an sr-only copy sits in a role="status" region: aria-busy alone is not reliably announced, and a bare sr-only span only announces inside a live region. Motion: motion-safe: guards the rotation (WCAG 2.3.3), with no bare animate-spin beside it that would void the guard — Spinner.test.tsx asserts both halves. Not focusable, not interactive: no focus ring applies. Size "xl" (32px) changes no colour and adds no pair: the same --icon-highlight stroke is painted on a larger box, and a larger graphical object cannot lower a contrast ratio. Line weight scales with it rather than being pinned — the glyph is stroke-width 2 in a 24-unit viewBox, so the painted stroke is box/12 at every tier (1.33 / 1.67 / 2.00 / 2.67px), and Spinner.test.tsx locks both inputs of that ratio across the scale.