---
title: "Spinner"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/spinner"
source: "https://opsinjs.pensievelabs.org/components/spinner.md"
section: "Components"
status: "shipped"
kind: "component"
category: "feedback"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["loader", "busy indicator", "activity indicator"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="spinner"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured, so whether the muted ring clears the non-text floor on a card and on the page is argued rather than checked.&#x22;,
  &#x22;Nobody has heard the role=\&#x22;status\&#x22; region in a screen reader, so whether it announces the wait once by name, rather than twice, is untested.&#x22;,
  &#x22;Forced colours are unhandled, because the SVG stroke is left where the author set it, and whether the ring stays visible there is untested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="spinner" />

## Installation [#installation]

<ComponentInstall name="spinner" unbuilt="false" importPath="@/components/ui/spinner" />

## Usage [#usage]

```tsx
import { Spinner } from "@/components/ui/spinner"
```

```tsx
<span className="inline-flex items-center gap-2 text-muted-foreground">
  <Spinner label="Loading your readings" />
  <span>Loading</span>
</span>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A brief in-place wait whose result has no known shape, so a skeleton would have no single outline to promise.&#x22;,
  &#x22;A spot too small to draw an outline in, such as a toolbar slot or a compact inline control.&#x22;,
  &#x22;A wait short enough that a skeleton would flash in and straight back out.&#x22;,
]"
  avoid="[
  { case: &#x22;The shape of what is arriving is known and can be drawn: a card, a list, a chart, a block of text.&#x22;, instead: &#x22;skeleton&#x22; },
  { case: &#x22;The wait belongs to a control performing its own action, such as a submit button saving a reading.&#x22;, instead: &#x22;button&#x22; },
  { case: &#x22;You can measure how far through a known sequence of steps the reader is, so the wait is determinate.&#x22;, instead: &#x22;stepper&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="spinner"
  parts="[
  {
    name: &#x22;Spinner&#x22;,
    describes: &#x22;The root, an inline span with role=\&#x22;status\&#x22;. A live region carrying the accessible name from label.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Spinner.Ring&#x22;,
    describes: &#x22;The turning SVG arc, aria-hidden and drawn in currentColor. Sized in em, held still under reduced motion.&#x22;,
    prop: &#x22;size&#x22;,
  },
  {
    name: &#x22;Spinner.Label&#x22;,
    describes: &#x22;A visually hidden copy of the name, so the live region has text to announce.&#x22;,
  },
]"
/>

## Examples [#examples]

### A brief wait [#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.

<ComponentPreview name="spinner-a-brief-wait" kind="example" align="start" />

### Two sizes [#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.

<ComponentPreview name="spinner-two-sizes" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **A ring beside "Loading" for a two-second action with no known shape.**
  </DoDont.Do>

  <DoDont.Dont>
    **A ring where a list is loading.** The list has an outline, so it wants a skeleton.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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`.

<KeyboardTable
  name="spinner"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the spinner&#x22;,
    notes: &#x22;A spinner is status, not a control. Nothing here is focusable.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the spinner&#x22;,
    notes: &#x22;Same in reverse. A spinner never appears in the focus order.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;There is nothing to select, move or expand. The spinner captures no key.&#x22;,
  },
]"
/>

<ContrastReport component="spinner" />

## API reference [#api-reference]

<PropsTable name="SpinnerProps" />

`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 [#related]

* [Skeleton](./skeleton.mdx) is the outline of content that has not arrived, and it is the default loading indicator here.
* [Button](./button.mdx) is where a wait belonging to a control lives, and it draws its own busy glyph.
* [Progress](./progress.mdx) is the determinate bar, delegated to Base UI rather than shipped here.
* [Stepper](./stepper.mdx) is the determinate wait through a named sequence of steps.
