Spinner
betaA turning ring beside the control a person used, while their request runs, with a required label for screen readers.
Preview
size
color
search field
switch
with Skeleton
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/spinner
Use it for
- A spinner only marks a wait the person started, at the control they used. It never stands in for content. Content waits with Skeleton.
- Put it beside the control that started the wait, like a search field fetching results or a switch saving its new value.
- Give it a label that says what is happening, like "Searching orders". TypeScript requires one.
- It takes the text color around it. Set the color with a text token, like className='text-muted-foreground'.
- Use size to match what is beside it: sm beside text-sm or a small control, md beside a medium control, lg beside a large one.
Avoid it for
- A button that is saving. Use loading on the Button. It keeps the width and blocks repeat clicks.
- Content that is loading, like a list, a panel, or a page, even when you do not know its shape. Use Skeleton in its place.
- Progress you can measure. Show how far along it is instead.
- Waits that finish in well under a second. The spinner only flashes. Show nothing until the result arrives.
- Several spinners at once for one wait. Use one, at the control the person used.
Props
- label
Required. What is happening, in words, like "Searching orders". Rendered as visually hidden text inside the status.
string
- size
Diameter: half, three quarters, or all of a small control's height. Density scales it.
"sm" | "md" | "lg" · default "md"
- className
Extra classes. Later classes win, so className='size-12' replaces the size and 'text-primary' sets the color.
string
- children
Not allowed. The label is the content.
never
| Prop | Type | Default | What it does |
|---|---|---|---|
| label | string | - | Required. What is happening, in words, like "Searching orders". Rendered as visually hidden text inside the status. |
| size | "sm" | "md" | "lg" | "md" | Diameter: half, three quarters, or all of a small control's height. Density scales it. |
| className | string | - | Extra classes. Later classes win, so className='size-12' replaces the size and 'text-primary' sets the color. |
| children | never | - | Not allowed. The label is the content. |
Accessibility
- Renders a <span role="status">, a polite live region. The label is its text, in a visually hidden span.
- TypeScript requires label, so a spinner always has words. A live region is announced by its text, so the label is text, not aria-label.
- Some screen readers miss a status region that arrives together with its text, and some hold back changes inside a region marked aria-busy. When people must hear that loading started, keep one role="status" element on the page, outside any busy region, and change its text, as the Skeleton docs show.
- The ring is an aria-hidden <svg>. It is not focusable and adds no tab stop.
- It turns once per 0.4 × --motion-pulse-duration. The theme sets that to 0s for motion off and for reduced motion, so the spinner stays still then. A theme without the variable also stays still.
- The arc is currentColor, and the track is the same color at 25%. Put it on text the theme already checks, like muted-foreground, which is held to 4.5:1 on the page.
Examples
A spinner with a label
<Spinner label="Searching orders" />Beside a search field, with Skeleton for the results
function OrderSearch({ results }: { results: string[] | null }) {
return (
<div className="grid gap-3">
<div className="flex items-center gap-2">
<Input type="search" aria-label="Search orders" />
{results === null && <Spinner label="Searching orders" className="text-muted-foreground" />}
</div>
{results === null ? (
<Skeleton className="h-20 w-full" />
) : (
<ul>
{results.map((result) => (
<li key={result}>{result}</li>
))}
</ul>
)}
</div>
);
}Beside a switch that saves
<div className="flex items-center gap-2">
<Switch defaultChecked>Auto-save</Switch>
<Spinner label="Saving" className="text-muted-foreground" />
</div>