Spinner
An indeterminate loading ring for a brief in-place wait whose shape is unknown, reached for only where a skeleton cannot say what is arriving.
Preview
spinner · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/spinner
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Spinner } from "@/components/ui/spinner"<span className="inline-flex items-center gap-2 text-muted-foreground">
<Spinner label="Loading your readings" />
<span>Loading</span>
</span>When to use it
Use it when
- A brief in-place wait whose result has no known shape, so a skeleton would have no single outline to promise.
- A spot too small to draw an outline in, such as a toolbar slot or a compact inline control.
- A wait short enough that a skeleton would flash in and straight back out.
Do not use it when
- The shape of what is arriving is known and can be drawn: a card, a list, a chart, a block of text.Use
skeletoninstead. - The wait belongs to a control performing its own action, such as a submit button saving a reading.Use
buttoninstead. - You can measure how far through a known sequence of steps the reader is, so the wait is determinate.Use
stepperinstead.
Anatomy
SpinnerThe root, an inline span with role="status". A live region carrying the accessible name from label.Controlled bylabelSpinner.RingThe turning SVG arc, aria-hidden and drawn in currentColor. Sized in em, held still under reduced motion.Controlled bysizeSpinner.LabelA visually hidden copy of the name, so the live region has text to announce.
Examples
A brief wait
A ring beside a visible word, for a wait with no known shape. label names the
wait for a screen reader while the visible caption carries it for everyone else.
spinner-a-brief-wait · base base · style base-lyraOpen under the product themeTwo sizes
size sets sm and md side by side. Each is measured in em, so the ring
grows with the surrounding text rather than pinning at a fixed size.
spinner-two-sizes · base base · style base-lyraOpen under the product themeContent guidelines
Name what is being waited for. "Loading your readings" tells a reader which part of the page is not ready, where a bare "Loading" does not. Where the wait runs long enough that a reader wonders whether anything is happening, a ring is the wrong indicator.
Do
A ring beside "Loading" for a two-second action with no known shape.
Don’t
A ring where a list is loading. The list has an outline, so it wants a skeleton.
Accessibility
Audited against WCAG 2.2 AA in a source pass over the component and its examples and a rendered pass covering axe, target size, focus visibility and reflow at 320px wide. The audit is author run, not an independent review, and clinical review is still pending.
The source pass found nothing that needed changing. The root already carries
a required aria-label plus a visually hidden text twin, the SVG ring is
aria-hidden so the decorative motion stays out of the accessibility tree,
sizing is em based throughout with no px type step, and the ring draws only
in currentColor with no status colour or category colour of its own. The
rendered pass came back clean: no axe violation, no target size finding and
no horizontal scroll at 320px.
Two points stay open. The ring's contrast against either theme's surfaces
cannot be verified or hardened from source, because the ring takes whatever
ink colour the caller's context supplies rather than a colour the component
sets itself, so that gap belongs to the ContrastReport below rather than to
this file. And the development only missing label warning does not stop a
production caller from passing an empty or whitespace label, which matches
the same convention already used by divider and segmented-control.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the spinner | A spinner is status, not a control. Nothing here is focusable. |
| Shift+Tab | Skips the spinner | Same in reverse. A spinner never appears in the focus order. |
| Arrow keys | Nothing | There is nothing to select, move or expand. The spinner captures no key. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from SpinnerProps in registry/bases/base/spinner.tsx.
label is required and has no default, because a guessed name would describe the
wrong wait. size defaults to md and chooses visual weight only. There is no
colour prop: colour reaches the ring only through className, and the
two-colour-axes rule applies to that class in full.
Related
- Skeleton is the outline of content that has not arrived, and it is the default loading indicator here.
- Button is where a wait belonging to a control lives, and it draws its own busy glyph.
- Progress is the determinate bar, delegated to Base UI rather than shipped here.
- Stepper is the determinate wait through a named sequence of steps.