Components/Feedback
Spinner
Spinners say that something is in progress without saying how far along it is. They suit a wait whose length is unknown and whose result has no shape to stand in for — a request that has been sent and has not come back yet.
import { Spinner } from '@growth-protocol-ai/alpha';When to use
Reach for a spinner when work is under way and there is nothing honest to say about how far along it is — a request in flight, a form being submitted, a panel fetching for itself. Put the ring where the result will appear, so it answers the question the reader is already asking, and take it away the moment the content lands.
When not to use
Do not use a spinner where the shape of what is coming is already known — a list of rows, a card, a paragraph of copy. That is Skeleton, which draws the outline the content will fill so the page does not jump when it arrives. The two are told apart by that one question rather than by how long the wait is: a shape to stand in for is a skeleton, no shape is a ring.
size5 values
Size is the only decision, and what answers it is whatever the ring sits next to — not how important the wait is. The stroke thickens with the diameter, from 2px to 4px, so the ring keeps the same weight at every size, and the speed never changes: 0.8s a turn everywhere, because a faster spinner does not mean a shorter wait.
| size | When to use |
|---|---|
xs | 16px. Inline in running text, and inside a control that is already small. |
sm | The default, 20px. A glyph in a regular button, and the size to reach for when nothing suggests otherwise. |
md | 24px. Large buttons, and action triggers that stand on their own. |
lg | 32px. A card waiting on its own fetch. |
xl | 48px. A whole page or a blocking overlay, where the ring is the only thing on screen. |
Inside a button, reach for the button’s own loading prop. It draws its own ring, sets aria-busy and blocks the second press; a Spinner dropped in beside the label does none of that.
One announcement per wait. Where the ring sits inside a control that already sets aria-busy, pass aria-hidden and let the control speak — two live regions describing the same pause is worse than neither.
Name what is loading once a page waits on several things. The default label is “Loading”, which is enough for one fetch and useless for four.
inverse is for --button-primary surfaces, not for dark mode. The default ring already follows the theme, because --button-primary does.
A turning ring is not progress. Where the percentage is known, show the percentage — Spinner has no determinate mode and will not grow one.
The turn slows under prefers-reduced-motion rather than stopping, because a still ring reads as a wait that has ended. Do not stop it from outside.
On a filled surface
The default ring is --button-primary, which is also the fill of a primary button — drawn on one, it disappears. The first two below are the same button and differ only in inverse; the third is where the default belongs. Every ring here is aria-hidden, because the control around it is what should speak, and a ring in an icon slot is only worth showing because a primary button is the one brand-filled surface the package publishes.
| Prop | Type | Default | Notes |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "sm" | A step of the shared size scale, not a pixel count — xs and md sit either side of the default. The stroke thickens with the diameter, so see the table above for what each one draws. |
inverse | boolean | false | Repaints the ring in --text-inverse, for a --button-primary fill. |
role | AriaRole | "status" | A polite live region, so the wait is announced when the ring mounts. The default is an announcement, not a lock — pass your own and it wins. |
aria-label | string | "Loading" | What that region announces. Name the thing being waited for once a page waits on more than one. |
children | never | — | The ring is a gradient behind a mask and has no inside, so it takes none. |