Standalone loading indicator primitive. Three pure-CSS variants (ring / dots / pulse), five sizes plus numeric escape hatch, seven palette role colors plus currentColor fallback, prefers-reduced-motion aware. No JS render loops, no Motion library.
Spinner
The standalone loading indicator. Drop <Spinner /> anywhere — beside text, inside
a button, as an EmptyState graphic — and it does the right thing with a few hundred
bytes of CSS-driven motion. No JS render loops. No Motion library. No useEffect.
All three are pure CSS. The ring uses Tailwind's built-in animate-spin; the
other two use two custom keyframes registered in the DS Tailwind preset
(spinner-bounce + spinner-pulse), so any consumer of the preset gets the
animations for free without an extra globals.css import.
Sizes
Token
Diameter
Common use
xs
12 px
Inside small badges / chips
sm
16 px
Inline with body text, inside small buttons
md
20 px
Default. Standalone next to a label.
lg
32 px
Card-level loading state
xl
48 px
Section-level loading state
number
— px
Escape hatch: <Spinner size={120} /> for graphics.
Speeds
Speed
Duration
Notes
slow
1200 ms
"Deliberate, still working."
normal
800 ms
Default. Matches Tailwind's animate-spin.
fast
500 ms
"Almost done."
Routed via inline animation-duration, so the keyframe utility class stays
stable across every speed.
Accessible label. Visible when labelPlacement !== 'hidden'.
labelPlacement
'hidden' | 'end' | 'bottom'
'hidden'
Where the label renders. hidden is sr-only.
className
string
—
Merged via tailwind-merge. Last-wins.
sx
Sx
—
Theme-aware inline style.
Accessibility
Wrapper announces.<Spinner /> carries role="status" + aria-busy="true" +
aria-live="polite". Screen readers announce the loading state without taking focus.
Label semantics adapt to placement. When labelPlacement="hidden" the label is
applied as aria-label on the wrapper and no inner span is rendered — avoids
double-announcement. When labelPlacement="end" | "bottom" the visible label text
becomes the announcement (no wrapper aria-label).
The animated glyph is aria-hidden. SVG, dot row, pulse disc — all hidden from
AT. Only the wrapper (or its visible label child) is in the accessibility tree.
prefers-reduced-motion. All three variants halt under reduced motion via the
Tailwind motion-reduce: variant. The label still announces. The glyph stays
visible as a static representation.
axe-core. Zero violations across the variant × color × labelPlacement matrix.
currentColor fallback. Drop <Spinner /> (no color prop) inside a
<Button color="primary"> or an <a> and it inherits the surrounding text color
automatically. The single most common pattern needs no role override.
Theming
ts
defineTheme({ components: { Spinner: { defaultProps: { variant: 'dots', speed: 'slow' }, styleOverrides: { root: 'gap-3', // wider visible gap when a label is rendered label: 'font-medium', }, }, },});
defineTheme({ components: { Spinner: { defaultProps: { variant: 'dots', speed: 'slow' }, styleOverrides: { root: 'gap-3', // wider visible gap when a label is rendered label: 'font-medium', }, }, },});
Props
Prop
Type
Default
Description
color
ResponsiveValue<SpinnerColor>
—
Palette role driving the spinner glyph color. Omit to inherit `currentColor` from the
surrounding text — the right move when nesting inside `<Button>`, `<a>`, or any colored
surface that already sets `color`.
label
string
—
Accessible label. Defaults to `"Loading"` so the spinner is always announced. Override to
disambiguate (`"Loading users"`, `"Fetching invoices"`, …) or to localize.
labelPlacement
enum
'hidden'
Where (or whether) to render the visible label text.