---
title: Spinner
description: One ring, one border, one turn - with a visually hidden label, because a spinner with nothing to announce is just an animation.
source: https://adamjurek.com/components/spinner
---

## Home


Spinner is a single turning ring with a label that is visually hidden but
still announced through `role="status"`. Reach for it whenever there is an
operation in flight worth telling someone about, not as decoration next to
something that already finished.

<!-- ::start:showcase demo="spinner" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The hidden label is why it exists as a status rather than just a CSS
animation: a spinner with nothing to announce is only motion, not
information. Reduced motion swaps the turn for a slower pulse instead of
removing it outright, because a spinner is still communicating "in progress
right now", unlike a skeleton, which just goes still.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/spinner.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/spinner.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Loading |
| Files | `spinner.tsx` |
| Dependencies | None |
| Tags | loading, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Spinner } from "@sushindustries/ui";

export function Example() {
	return <Spinner label="Loading the example" />;
}
```

## What you should see

An 18px ring, accent-coloured on one edge, turning steadily. There is no
visible text - `label` is read by a screen reader through `role="status"`
and an `sr-only` span, not printed on the page. Set `prefers-reduced-motion:
reduce` and the ring stops spinning and pulses in place instead.

## If nothing happens

A ring that sits still without pulsing, rather than turning, usually means
reduced motion is on somewhere in the chain - check the OS setting before
assuming the component is broken. A ring with no colour at all - flat grey,
no accent edge - means the atoms stylesheet did not load.


## Guides


## Composing it

`Spinner` is `display: inline-block` and sized in pixels through `size`, so
it drops into a line of text or beside a button label without needing a
container of its own - `<Button disabled><Spinner size={14} />Saving</Button>`
works with no extra markup.

## Motion and reduced motion

The ring's turn is a CSS animation, and under `prefers-reduced-motion:
reduce` it is replaced with a slower opacity pulse rather than removed
outright - unlike `Skeleton`, which just goes still. A spinner communicates
that something is happening right now, so it keeps some movement; it is the
sweep, not the motion itself, that reduced motion objects to.

## When not to use it

Not a progress bar - there is no percentage prop and never will be, because
a ring has no notion of "how far". Reach for `Progress` when there is a
measurable amount left, and for `Skeleton` when the thing waited for is
content that should stay silent rather than announce itself.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `size?` | `number` | `18` | Pixel size of the ring. |
| `label?` | `string` | `"Loading"` | What is being waited for. Announced, never drawn. |

<!-- /generated:api -->

## Notes

`label` is always announced, even at the default - there is no way to
render a spinner with no accessible name, because a spinning ring nobody
can hear a reason for is worse than a generic one. `size` only scales the
ring's diameter; the border stays a fixed 2px, so a very large `size` reads
as a thin ring rather than a chunky one.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="spinner" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useState } from "react";
import { Spinner } from "@sushindustries/ui";

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
	const [saving, setSaving] = useState(false);

	return (
		<button
			type="button"
			className="btn"
			disabled={saving}
			onClick={async () => {
				setSaving(true);
				await onSave();
				setSaving(false);
			}}
		>
			{saving ? <Spinner size={14} label="Saving" /> : "Save"}
		</button>
	);
}
```

## What this example is not

Swapping the button's own text for the spinner, rather than showing both,
is a choice this example makes - `Spinner` has no opinion on whether it
replaces or sits beside a label; that layout is the caller's.
