DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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

PropTypeDefaultDescription
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
trackbooleantrueShow the full-circle background track. ring variant only. Set false for an arc-only style
labelstring | 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
delaynumber0Milliseconds before anything renders. A response inside the window never flashes a spinner
dir"ltr" | "rtl"inheritedText direction. Omit to inherit from the container; in RTL "left" maps to inline-start (visually right) and "right" to inline-end
classNamestring—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

VariantDescription
ringRotating arc (~75% of circumference) on an optional dimmed circular track
dotsThree bouncing dots with 150ms stagger delays
pulsePulsing circle with animate-ping outer ring and solid inner circle

Label positions

ValueLayout
noneLabel hidden — screen reader only, as sr-only text inside the status region
topflex-col, label above the spinner
bottomflex-col, label below the spinner
leftflex-row, label to the left (inline-start in RTL)
rightflex-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 and text-primary/30 for the ring track.
  • RTL: The root takes dir, and a plain flex-row follows it — "left" lands on inline-start (visually right) and "right" on inline-end (visually left). There is deliberately no flex-row-reverse: combined with dir="rtl" it would double-reverse and cancel out. Omit dir to 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.

SpacerSplit Button

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsSizesIntentsTrackWith labelOn colorInside a buttonDelayAPI ReferencePropsVariantsLabel positionsNotesAccessibility