Spinner
Indeterminate loading indicators with ring, dots, and pulse variants, five sizes, intent colors, on-color tone for colored surfaces, a decorative mode for use inside controls, delay to prevent flash, label positions, and LTR/RTL support.
pnpm add @tessinaui/uiWhen to use
- Work of unknown length. The spinner is indeterminate: it says something is in progress, not how far along it is.
- A whole view or one section loading. Place it over the area that is waiting, alone or with a label.
- Work that can finish quickly. Set
delayso a fast response never flashes a spinner at all. - A label that names the work. Prefer a verb for the actual task ("Saving...") over a bare "Loading"; use
labelPositionto show it. - Inside a control that is busy. Render it decorative with
label={null}and let the control carry the state througharia-busy.
When not to use
- When progress can be measured. Use Progress, which can show how much is done and how much is left.
- For content whose shape is known. Use Skeleton, which holds the layout and avoids a jump when content arrives.
- Several at once. Let one indicator stand for the overall loading state; many compete for attention and, with labels, for the screen reader.
- For an outcome. The semantic intents match the surface the spinner sits on; they do not signal success or failure.
- As a hand-wired loading cue on a button. Use the button's own
loadingprop, which already renders a decorative spinner and setsaria-busy.
Installation
pnpm add @tessinaui/uiUsage
import { Spinner } from "@tessinaui/ui";{/* Default ring */}
<Spinner />
{/* Brand primary color */}
<Spinner intent="primary" />
{/* Arc-only (no track) */}
<Spinner track={false} />
{/* Label above */}
<Spinner labelPosition="top" />
{/* Label to the right */}
<Spinner labelPosition="right" label="Saving…" />
{/* RTL — label maps to inline-start (visually right) */}
<Spinner labelPosition="left" dir="rtl" label="جارٍ التحميل" />
{/* Dots, large, error */}
<Spinner variant="dots" size="lg" intent="error" />
{/* Pulse, success */}
<Spinner variant="pulse" intent="success" />
{/* On a colored surface — tone is inherited from <Surface> */}
<Surface background="primary">
<Spinner label="Publishing…" labelPosition="right" />
</Surface>
{/* Inside a control: decorative. The button owns the state via aria-busy. */}
<button aria-busy="true" disabled>
<Spinner size="sm" label={null} /> Save
</button>
{/* Don't flash on fast responses */}
<Spinner delay={300} />Examples
Default
A basic ring spinner with the default size and intent.
Variants
Three animation styles — ring, dots, and pulse.
Sizes
Five size tokens from xs to xl.
Intents
Color intents including the brand primary, plus feedback colors.
Track
The ring variant with the background track on (default) versus off for an arc-only style.
With label
A visible label placed beside or beneath the spinner, with RTL support.
On color
Inside a colored <Surface> the spinner inherits tone="on-color" and paints the surface's ink — the same rule the button family follows, so nothing needs a per-consumer color override.
Inside a button
The button family renders its spinner decorative (label={null}) and carries the loading state itself through aria-busy. A loading Save button announces "Save", not "Loading Save".
Delay
With delay, a response that arrives inside the window never shows a spinner at all — a fast request doesn't flash.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "ring" | "dots" | "pulse" | "ring" | Visual style of the animation |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size of the spinner |
intent | "none" | "primary" | "error" | "warning" | "success" | "info" | "none" | Color intent. A spinner depicts "in progress" — the semantic intents exist to match the surface it sits on (an error banner, a success toast), not to signal an outcome |
tone | "default" | "on-color" | inherited | "on-color" paints currentColor so the spinner takes the surrounding ink. Inherited from a wrapping <Surface>; an explicit prop wins |
track | boolean | true | Show the full-circle background track. ring variant only. Set false for an arc-only style |
label | string | null | "Loading", Arabic under dir="rtl" | What the spinner announces — prefer a verb for the actual work ("Saving…"). Shown visually when labelPosition ≠ "none". null makes the spinner decorative: no role, hidden from assistive technology |
labelPosition | "none" | "top" | "bottom" | "left" | "right" | "none" | Where to render the visible label. "none" keeps it screen-reader only |
delay | number | 0 | Milliseconds before anything renders. A response inside the window never flashes a spinner |
dir | "ltr" | "rtl" | inherited | Text direction. Omit to inherit from the container; in RTL "left" maps to inline-start (visually right) and "right" to inline-end |
className | string | — | Additional class on the root span |
All other native <span> attributes (id, style, data-*, aria-describedby …) are forwarded to the root. The root exposes data-slot="spinner", data-variant, and data-tone; the animation carries data-slot="spinner-indicator" and a visible label data-slot="spinner-label".
Variants
| Variant | Description |
|---|---|
ring | Rotating arc (~75% of circumference) on an optional dimmed circular track |
dots | Three bouncing dots with 150ms stagger delays |
pulse | Pulsing circle with animate-ping outer ring and solid inner circle |
Label positions
| Value | Layout |
|---|---|
none | Label hidden — screen reader only, as sr-only text inside the status region |
top | flex-col, label above the spinner |
bottom | flex-col, label below the spinner |
left | flex-row, label to the left (inline-start in RTL) |
right | flex-row, label to the right (inline-end in RTL) |
Notes
- track=false: Removes the background circle — only the rotating arc is visible. Use it on a busy or coloured surface, where the track reads as a stray ring.
- primary intent: Uses
text-primary(brand color) for the arc/dots/pulse andtext-primary/30for the ring track. - RTL: The root takes
dir, and a plainflex-rowfollows it —"left"lands on inline-start (visually right) and"right"on inline-end (visually left). There is deliberately noflex-row-reverse: combined withdir="rtl"it would double-reverse and cancel out. Omitdirto inherit from the page; pass it only to override an ancestor. - One spinner per view. A single indicator should stand for the overall loading state; several at once compete for attention and, with labels, for the screen reader. For content with known dimensions, prefer a Skeleton.
Accessibility
Spinner has exactly two accessibility shapes, chosen by label:
Labelled (default). The root is a role="status" live region. Its announcement is its content: an sr-only copy of the label written 150ms after mount (the visible label is aria-hidden), because VoiceOver + Safari ignores a status region that appears with its text already inside. VoiceOver + Safari passed 2026-09-14 — the label is spoken as the spinner appears. There is no aria-label, so the text is never spoken twice and you localize simply by passing text. Prefer a verb for the work being done: label="Saving…" beats "Loading".
Decorative (label={null}). No role, aria-hidden. Use this whenever the spinner sits inside a control that already announces its own state — otherwise the control's name absorbs the spinner's and a Save button reads "Loading Save". The control carries the state instead:
<button aria-busy="true" disabled>
<Spinner size="sm" label={null} /> Save
</button>Button, IconButton, Fab, SplitButton, ToggleButton, ButtonGroup, Switch and ActionSheet already do this for their loading prop.
Never a progressbar. Spinner is indeterminate only and does not claim role="progressbar"; that contract — with aria-valuenow — belongs to a determinate Progress.
Reduced motion. Under prefers-reduced-motion: reduce every variant keeps signalling activity through opacity alone: the ring's rotation becomes a 1.5s opacity pulse (a frozen arc would read as broken), the dots' bounce becomes the same pulse with their stagger intact, and the pulse variant drops its expanding ping ring and lets the core pulse. Nothing stops; nothing translates or scales.
Contrast. Default tone uses the page palette and is gated by audit:contrast: every intent clears the 3:1 WCAG 1.4.11 asks against the page except light-theme intent="warning", whose amber arc reads 2.2:1 — the intent palette's accepted ceiling (§9, TES-272). Motion, not hue, carries the signal (an opacity pulse under reduced motion), and the label announces it. On-color paints currentColor, so legibility is whatever the surrounding surface's ink already guarantees.
What doesn't apply. The radius cascade (§2) has nothing to act on — every shape is a circle. Touch targets (§5, WCAG 2.5.5) don't apply either: a spinner is never an interactive target, so sizes below 44px are correct.