Spinner
An indeterminate wait, sized to the thing it replaces.
@luc/uiExamples
A spinner inside the control that is waiting
`loading` keeps the label, sets `aria-busy`, blocks a second press and renders a spinner with `label={null}` — so the wait is announced once, by the button.
Press one: the spinner replaces the leading edge, the label stays, and the button refuses a second press.
const [busy, setBusy] = useState(false);
<Button loading={busy} loadingLabel={t("rebalancing")} onClick={run}>
{t("rebalance.portfolio")}
</Button>
{/* What `loading` renders for you: */}
<Spinner size={16} label={null} />Five sizes
The scale is the library's own: 16 inside a control, 24 in a panel header, and nothing in between invented locally.
14
16
18
20
24
<Spinner size={16} label={null} />One announcement, not two
A region that is already announcing its own wait passes `label={null}`: the spinner is then a picture of the wait rather than a second live region.
Loading the last 30 days
<Panel aria-busy="true">
<Spinner size={16} label={null} />
<span>Loading the last 30 days</span>
</Panel>A wait with a shape
The two are not interchangeable: a spinner says "wait", a skeleton says "this is what is coming".
Spinner · unknown length
Validating… (no estimate)
Skeleton · known shape
{/* Unknown length */}
<Spinner size={20} label="Validating" />
{/* Known shape */}
<SkeletonText lines={2} className="w-32" />In a panel header, sized to its own type
Here the spinner carries the label, because nothing else on the surface says what is loading.
Positions
Refreshing positions6 open · last refresh 12:04 UTC
<Spinner size={16} label="Refreshing positions" />Usage
import { Spinner } from "@luc/ui";<Spinner size={18} label="Loading runs" />Pass `label={null}` when an ancestor already announces the wait, and the spinner stops being a second live region.
Package setup — peer requirements, the stylesheet and the provider — is on installation.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
One symbol with two props: a size from the library's own scale (14 to 24) and an optional accessible label.
| Export | Kind | Signature |
|---|---|---|
Spinner | 2 documented props | ({ size, label, className, ...props }: SpinnerProps) => import("@types/react").JSX.Element |
Spinner props
Type: SpinnerProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
label? | string | null | undefined | — | Screen-reader text. Set to `null` when an ancestor already announces. |
size? | SpinnerSize | undefined | — |
Guidelines
Do
- Use a spinner for a wait with no measurable end, sized to the thing it replaces: 16px inside a button, 24px in a panel.
- Announce the wait once — a button that already says it is working passes `label={null}` to the spinner inside it.
- Reach for a skeleton instead when the shape of the result is known, because the reader sees what is coming.
Don't
- Do not put a spinner on every card of a page; six spinners announce six waits at the same time.
- Do not use one for a job with a known size — a progress bar tells the reader more.
- Do not re-implement the rotation with a custom keyframe; the component already respects reduced motion.
Accessibility
With a `label` the spinner is `role="status"` and carries that name; with `label={null}` it renders no role and no name, which is how a caller avoids a second announcement inside a control that is already busy. Under `prefers-reduced-motion` the rotation becomes a slow pulse rather than stopping, because a frozen indicator reads as a bug.
Family
Feedback
What the product says when something happened, is happening, or did not.
10 documented entries in this family. Open the family index
Related