Skip to content
txbrendan/ui

Spinner

beta

A turning ring beside the control a person used, while their request runs, with a required label for screen readers.

Preview

size

Loading, smallLoading, mediumLoading, large

color

Loading in muted textLoading in brand text

search field

Searching orders

switch

Saving

with Skeleton

Searching customers

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>