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.
ringRotating cometdotsBouncing dotsbarsFading spokespulseBreathing haloBase 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:
| Variant | Reads as | Reach for it when |
|---|---|---|
ring | Neutral, mechanical | The default. Anything blocking — a form submitting, a route loading. |
dots | Friendly, conversational | Inline 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. |
bars | System, unobtrusive | Something that may spin for a long time, or that must animate identically for every user — see the reduced-motion section below. |
pulse | Ambient, 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.
smmdlgThe 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.
primarysecondarydangerneutralinside 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.
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.
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".
ringstops rotating:--forte-spin-turncollapses to0turn. A second animation that is always running — and is a no-op at full motion, because--forte-pulse-dipis1there — takes over as a slow opacity breathe.dotsstop hopping: the hop is a--forte-travel-*token, so it goes to0px. The staggered opacity wave is untouched and carries the whole thing.barsdo 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.pulsestops 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 always1 - --forte-motion-ok— is what removes it.
Theming
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.
| Property | Controls | Default |
|---|---|---|
--forte-spinner-size | Diameter 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-thickness | Ring stroke and halo border. | max(2px, calc(var(--forte-spinner-size) / 8)) |
--forte-spinner-gap | Space between the indicator and a visible label. | var(--forte-control-gap) |
--forte-spinner-duration | One full cycle. variant="pulse" raises it to --forte-duration-loop-pulse. | var(--forte-duration-loop-spin) |
--forte-spinner-color | The indicator's colour. Becomes the indicator's color, and every part fills with currentColor. | var(--forte-color-primary) |
--forte-spinner-track | The 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-color | Colour of a visible label. | var(--forte-color-foreground-muted) |
--forte-spinner-label-size | Font size of a visible label. Retuned by size. | var(--forte-font-size-2) |
--forte-spinner-dot-dim | How far a dot fades at the bottom of its hop. | 0.25 |
--forte-spinner-bar-dim | How far a bar fades at the back of the sweep. | 0.15 |
--forte-spinner-bar-width | Thickness of one spoke. | calc(var(--forte-spinner-size) / 9) |
--forte-spinner-bar-length | Length of one spoke. | calc(var(--forte-spinner-size) / 3.2) |
--forte-spinner-dot-travel | How 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-min | Scale 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
decorative | boolean | false | Stops 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. |
label | string | Loading | What 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. |
labelPlacement | SpinnerLabelPlacement | hidden | Where 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. |
size | SpinnerSize | md | Diameter 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. |
tone | SpinnerTone | primary | Which 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. |
variant | SpinnerVariant | ring | Which 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-*.