Skip to content

Spinner

Use a spinner when a wait is short, indeterminate, and the user is expected to sit through it. If you know how far along the work is, show that instead — a progress bar answers "how much longer", which is the question a spinner cannot. If the wait replaces content the user was already reading, a Skeleton keeps the layout still and tells them what is coming back.

spinner/variants.tsx
ringRotating comet
dotsBouncing dots
barsFading spokes
pulseBreathing halo

Base UI has no spinner primitive, so this is the one component in the library not built on one — there is no interaction to model and no state machine to get right. What is left is the part that is usually got wrong: announcing the wait once and correctly, and animating it in a way that survives prefers-reduced-motion.

Import

import { Spinner } from "@forte-ui/react";

Examples

Variants

The four are interchangeable. Each is laid out as a fraction of --forte-spinner-size and none of them overflows it, so swapping variant never moves anything beside the spinner — including in a row of mixed ones.

Which to pick is a question of voice:

VariantReads asReach for it when
ringNeutral, mechanicalThe default. Anything blocking — a form submitting, a route loading.
dotsFriendly, conversationalInline with text, in chat, beside a label. Its box is square like the others, but the ink is a short wide band rather than a disc, so it sits on a line of text without looking like a hole punched in it.
barsSystem, unobtrusiveSomething that may spin for a long time, or that must animate identically for every user — see the reduced-motion section below.
pulseAmbient, alive"Still connected", "still listening". Its cycle is twice as slow as the others on purpose; it is not meant to convey urgency.

Sizes

size sets the diameter — 1rem, 1.5rem or 2rem — and everything else is derived from it. The ring's stroke, the dots and their gaps, the bars and the halo all scale together, so a sm spinner is a smaller spinner rather than a thinner one.

spinner/sizes.tsx
sm
md
lg

The label scales too, dropping to --forte-font-size-1 at sm and rising to --forte-font-size-3 at lg.

Tones

tone picks the colour set. Four are semantic; current is the composable one — it takes currentColor, so a spinner dropped inside a button, a link or a status message matches the text around it and keeps matching it through hover, disabled and theme changes.

spinner/tones.tsx
primary
secondary
danger
neutral

inside muted text

inside a danger message

Label and placement

The indicator is aria-hidden, because a picture of motion is not information. label is the only thing that reaches a screen reader, and it is rendered whether or not it is visible — labelPlacement="hidden" keeps it in the accessibility tree and out of the paint.

spinner/labels.tsx
Loading invoicesLoading invoicesLoading invoices

Say what is being waited for. "Loading invoices" tells someone whether the wait concerns them; "Loading" makes them guess. Show the label outright once the wait is long enough that the user deserves to know what it is for — roughly a second is the usual threshold.

In a loading panel

The realistic case, and the one worth copying: the live region is the panel, and it is in the DOM from the first render. The spinner inside it is decorative, so there is exactly one announcement for one wait.

spinner/loading-panel.tsx
Nothing loaded yet.

Reduced motion

Every variant keeps working under prefers-reduced-motion: reduce, and each one gets there differently. The two columns below are running side by side on the same page — the right-hand one carries data-forte-motion="reduce".

spinner/reduced-motion.tsx
Full motion
prefers-reduced-motion
  • ring stops rotating: --forte-spin-turn collapses to 0turn. A second animation that is always running — and is a no-op at full motion, because --forte-pulse-dip is 1 there — takes over as a slow opacity breathe.
  • dots stop hopping: the hop is a --forte-travel-* token, so it goes to 0px. The staggered opacity wave is untouched and carries the whole thing.
  • bars do not change at all. Nothing about them ever moved — the sense of rotation is entirely in the phase offset between eight fading spokes. That is what makes this the variant to choose when a spinner has to animate identically for everyone.
  • pulse stops expanding and cross-fades in place. The second halo is faded out entirely, which is not tidiness: the two haloes are distinguishable only by radius, and frozen at the same radius they would cross-fade in antiphase into a ring of near-constant opacity that reads as nothing happening. --forte-motion-off — which is always 1 - --forte-motion-ok — is what removes it.

Theming

spinner/theming.tsx
Hairline, slow
Heavy, no track
Wide, fast bars

Accessibility

The root renders a <span role="status">, which carries an implicit aria-live="polite" and aria-atomic="true". The indicator and all its parts are aria-hidden; the label is the entire accessible name.

decorative removes the role and the hidden label. It deliberately does not add aria-hidden to the root, so a label you asked to show stays in the accessibility tree as ordinary text — visible text that assistive technology cannot reach is a bug in its own right, not a feature of a decorative flag.

Under forced colours the dots, bars and halo fill with currentColor, which the UA remaps to CanvasText along with the rest of the page. The ring is the exception and needs help: forced colours remaps background-color, color and border-color but leaves background-image alone, so the conic gradient would keep its brand hue against a system-coloured page — and can vanish outright on a dark system theme. The a11y layer drops the gradient there and redraws the ring as a CanvasText border with one edge painted Canvas. The gap has to be an explicit system colour rather than transparent, which forced colours does not preserve either; a fully coloured ring looks static however fast it turns.

The dimmed dots and bars keep their partial opacity under forced colours, which elsewhere in the library is a trap — opacity is one of the few properties forced colours does not override, so a 55% disabled control keeps full contrast and still reads as enabled. Here it is the mechanism rather than a bug: those parts are mid-animation, not disabled, and every one of them reaches full opacity in turn.

There is nothing to focus and nothing to press, so a spinner is not in the tab order and takes cursor: default per the pointer rules.

Theming

Every visual decision is a custom property declared on the spinner root. Set them on the spinner itself — through className or an inline style — rather than on an ancestor: the component declares its own defaults on the root element, and an element's own declaration beats an inherited value.

Theming tokens for Spinner
PropertyControlsDefault
--forte-spinner-sizeDiameter of the indicator. size sets 1rem / 1.5rem / 2rem; everything else is a fraction of it and follows on its own.1.5rem
--forte-spinner-thicknessRing stroke and halo border.max(2px, calc(var(--forte-spinner-size) / 8))
--forte-spinner-gapSpace between the indicator and a visible label.var(--forte-control-gap)
--forte-spinner-durationOne full cycle. variant="pulse" raises it to --forte-duration-loop-pulse.var(--forte-duration-loop-spin)
--forte-spinner-colorThe indicator's colour. Becomes the indicator's color, and every part fills with currentColor.var(--forte-color-primary)
--forte-spinner-trackThe faint full ring the comet travels along. Set it to transparent for a bare arc.color-mix(in oklab, var(--forte-spinner-color) 18%, transparent)
--forte-spinner-label-colorColour of a visible label.var(--forte-color-foreground-muted)
--forte-spinner-label-sizeFont size of a visible label. Retuned by size.var(--forte-font-size-2)
--forte-spinner-dot-dimHow far a dot fades at the bottom of its hop.0.25
--forte-spinner-bar-dimHow far a bar fades at the back of the sweep.0.15
--forte-spinner-bar-widthThickness of one spoke.calc(var(--forte-spinner-size) / 9)
--forte-spinner-bar-lengthLength of one spoke.calc(var(--forte-spinner-size) / 3.2)
--forte-spinner-dot-travelHow high a dot hops. Collapses to 0px under reduced motion, because --forte-travel-sm does.min(var(--forte-travel-sm), calc(var(--forte-spinner-size) / 5))
--forte-spinner-ripple-minScale a halo starts from, as a factor. Gated on --forte-motion-ok, so it is exactly 1 under reduced motion and the halo holds still.calc(1 - 0.6 * var(--forte-motion-ok))

Two more values are written by the component rather than by you, and are listed here only so a var() typo is recognisable: --forte-spinner-count is the number of animated children the current variant renders, set on the root, and --forte-spinner-i is a child's index, set on each part. Every piece of per-child geometry is derived from the pair — a bar's angle is i / count of a turn, a dot's stagger is a fraction of the cycle — which is why the stylesheet needs no nth-child chain and why the bar count can be changed in one place.

Circles in this component use a literal 50% rather than var(--forte-radius-full), so data-forte-radius="none" cannot square off the ring, the dots or the halo — round is what those shapes are, not how they are styled. That is the same test Radio and the Button spinner apply, and the only grounds on which anything in the library opts out of the radius presets. The bars' end caps do follow the preset, because a squared-off spoke is a legitimate look.

API reference

Props for Spinner
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
decorativebooleanfalseStops the spinner announcing itself: no `role="status"`, and no hidden label. Use it whenever something else already announces the wait — a `Button` with `loading`, a panel that sets `aria-busy`, a live region that swaps in the loaded content. Two announcements for one wait is worse than one, and a page that mounts six spinners with six live regions is unusable. A label you asked to SHOW stays visible and stays in the accessibility tree, as ordinary text. Text that is on screen but hidden from assistive technology is a bug in its own right, so `decorative` will not do that — it only ever removes the live region and the invisible label.
labelstringLoadingWhat the spinner is waiting on. This is the only thing that reaches a screen reader — the indicator itself is `aria-hidden`, because a picture of motion is not information. Say what is happening rather than that something is: "Loading invoices" tells someone whether the wait concerns them; "Loading" does not.
labelPlacementSpinnerLabelPlacementhiddenWhere the label goes. `hidden` keeps it in the accessibility tree but out of the paint; `end` and `bottom` also show it, which is what you want for a wait long enough that the user deserves to know what it is for.
sizeSpinnerSizemdDiameter of the indicator — `1rem`, `1.5rem` or `2rem`. Every variant is laid out as a fraction of it, so all four occupy exactly the same box and can be swapped without moving anything around them.
toneSpinnerToneprimaryWhich semantic colour set the indicator draws from. `current` takes `currentColor` instead, which is what makes a spinner dropped inside a button or a link match the text beside it.
variantSpinnerVariantringWhich indicator to draw. All four are the same size and cost the same — this is a question of voice, not of capability. `ring` is a rotating comet, the neutral default. `dots` is three bouncing dots, which reads as friendlier and suits inline text. `bars` is eight fading spokes — the only variant whose animation is identical with and without reduced motion, so it is the safest choice for something that must look the same for everyone. `pulse` is a breathing halo, best for ambient "still working" states rather than for blocking ones.

Anything a <span> accepts passes through as well, including id, style and data-*.