opsinjs
ComponentsFeedback

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

Theme
Density
Text
Status
spinner · base base · style base-lyraOpen under the product theme

Installation

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 skeleton instead.
  • The wait belongs to a control performing its own action, such as a submit button saving a reading.Use button instead.
  • You can measure how far through a known sequence of steps the reader is, so the wait is determinate.Use stepper instead.

Anatomy

  1. SpinnerThe root, an inline span with role="status". A live region carrying the accessible name from label.Controlled by label
  2. Spinner.RingThe turning SVG arc, aria-hidden and drawn in currentColor. Sized in em, held still under reduced motion.Controlled by size
  3. Spinner.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.

Theme
Density
Text
Status
spinner-a-brief-wait · base base · style base-lyraOpen under the product theme

Two 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.

Theme
Density
Text
Status
spinner-two-sizes · base base · style base-lyraOpen under the product theme

Content 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.

KeyActionNotes
TabSkips the spinnerA spinner is status, not a control. Nothing here is focusable.
Shift+TabSkips the spinnerSame in reverse. A spinner never appears in the focus order.
Arrow keysNothingThere is nothing to select, move or expand. The spinner captures no key.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page