# Spinner

The one “working” indicator — a ring, never a shimmer.

- Group: Feedback
- Import: `import { Spinner } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/spinner/
- Related: skeleton, progress

## When to reach for it

A wait short enough that the shape of what is coming does not matter. Longer than that, use a Skeleton.

## Anatomy

- **Live region** (required) — The outer span, and the only part with a voice. It carries role="status" while there is a label; pass label={null} and it turns into an aria-hidden box with no role at all.
- **Ring** (required) — The inner span — 14px, 18px or 26px of border on a transparent box, and the only element size, tone and className reach.
- **Leading quarter** (required) — border-t, drawn in --ink at the default tone and in the inherited colour at current. It is the whole difference between a ring and a plain circle, which is why a still ring still reads as unfinished.
- **Screen-reader label** — An sr-only span holding label, present unless label is null. Announced once, when the spinner mounts, and never again.

## Best practices

### Do

- Pass tone="current" for a spinner on any filled ground: the default draws the leading quarter in --ink over a --rule-2 track, and inside a primary Button both of those are the ground it is sitting on.
- Announce the arrival somewhere else. The label is read once on mount and nothing is said on the way out, so a reader who heard “Loading projects” is never told the projects came.
- Reach for label={null} only inside a control that already names the operation — it hides the whole element from assistive tech rather than merely dropping the text, so a silenced spinner standing on its own is a wait nobody is told about.

### Don’t

- label defaults to the bare word “Loading”, so a Spinner written without the prop ships the exact announcement the prop exists to prevent — the default is a placeholder, not a value.
- className is merged onto the ring AFTER size and tone, so it beats both: <Spinner size="lg" className="size-4" /> is a 16px ring, and the prop whose whole job was naming the size is the one that lost.
- Nothing here sets aria-busy — Button does that for its own control — so a spinner laid over a panel leaves the panel announced as ready while its contents are stale and its buttons still take clicks.

## Accessibility

- label names the specific thing being waited on; three spinners all saying “Loading” tell a screen reader nothing.
- label={null} silences it for use inside a control that already announces the operation.
- Spins under motion-safe only; the static ring still reads as unfinished because the leading quarter is darker.

## Spinner

The system's one "working" indicator. A ring, not a blurred glow and not a shimmer: the White Reset has no light source, so depth and softness are not available to it. The leading quarter is the only thing that distinguishes the ring from a plain circle, which is why the track stays a hairline — a spinner is a hint that time is passing, not a feature of the page. It spins under `motion-safe` only. A reader who has asked for less motion gets a static ring, which still reads as "not finished" because the leading quarter is darker than the track. For a wait long enough that the reader would otherwise wonder whether the page is broken, prefer `Skeleton` — a shape that describes what is coming beats a dot that describes nothing. `className` reaches the RING, alongside `size` and `tone`, and overrides them: every utility a caller has for a spinner is about the ring, and merged onto the wrapper instead `className="size-8"` grew an invisible box around an unchanged 18px circle. Layout still works from there — the wrapper is `inline-flex` and takes the ring's margin box as its own.

### Props

- `size` — `'sm' | 'md' | 'lg'` default `'md'`. 14px / 18px / 26px. Match the size of the type it sits beside.
- `tone` — `'default' | 'current'` default `'default'`. `default` draws the ring against the page — a hairline track in the rule colour with the leading quarter in ink. `current` draws it in the inherited text colour, which is what a spinner inside a filled button needs: on an ink ground an ink ring is invisible.
- `className` — `string`. Merged onto the RING, after `size` and `tone`, so it overrides both.
- `label` — `string | null` default `'Loading'`. Announced to assistive tech. Pass the specific thing being waited on ("Loading projects"), not the generic word — a screen reader user hearing "Loading" three times cannot tell which three things. Pass `null` for a spinner that sits inside a control which already names the operation (a button whose own label changes to "Saving…"), so the two are not read out twice.

Also accepts: `HTMLAttributes<HTMLSpanElement>`.

## Example — sizes

```tsx
import { Spinner } from '@misoto22/folio'

<div className="flex items-center gap-6">
  <Spinner size="sm" label="Loading, small" />
  <Spinner size="md" label="Loading, medium" />
  <Spinner size="lg" label="Loading, large" />
</div>
```

## Example — on a filled ground

```tsx
import { Button, Spinner } from '@misoto22/folio'

<div className="flex flex-wrap items-center gap-4">
  <Button loading>Saving…</Button>
  <span className="inline-flex items-center gap-2.5 rounded-(--radius) bg-(--feature-surface) px-4 py-3 text-sm text-(--on-feature)">
    <Spinner size="sm" tone="current" label={null} />
    Indexing 1,204 frames
  </span>
</div>
```

## Example — which wait shape

```tsx
<div className="grid w-full gap-8 sm:grid-cols-3">
  <div className="flex flex-col gap-3">
    <Text size="xs" tone="muted">
      Unknown duration
    </Text>
    <Spinner label="Checking the deploy" />
  </div>
  <div className="flex flex-col gap-3">
    <Text size="xs" tone="muted">
      Known shape
    </Text>
    <SkeletonPage label="Loading the changelog" className="flex flex-col gap-3">
      <SkeletonLine className="h-2.5 w-20" />
      <SkeletonBlock className="h-10" />
    </SkeletonPage>
  </div>
  <div className="flex flex-col gap-3">
    <Text size="xs" tone="muted">
      Known fraction
    </Text>
    <Progress value={41} label="Uploading footage.mov" showValue />
  </div>
</div>
```
