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.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Spinner } from "@tessinaui/ui";{/* Default ring */}
<Spinner />
{/* Brand primary color */}
<Spinner intent="primary" />
{/* Arc-only (no track), Atlassian style */}
<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" | 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. Mirrors the Atlassian spinner style.
- 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 — the visible label, or an sr-only twin when labelPosition="none". 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. 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.