Skip to content

Spinner

An indeterminate activity indicator for short waits when the system is working and the result is on its way.

SourceRegistry
Syncing 24 files…
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerDemo() {
  return (
    <div
      role="status"
      className="flex items-center gap-3 rounded-full border bg-card py-2 pr-4 pl-3 text-sm text-muted-foreground shadow-raised"
    >
      <Spinner aria-hidden="true" label="" />
      Syncing 24 files…
    </div>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/spinner.json

Usage

import { Spinner } from "@/components/ui/spinner"
<Spinner label="Loading comments" />

prfct's spinner is twelve strokes that fade from solid to faint around the circle and rotate in twelve discrete steps. Stepped motion reads as "working" at a glance, and the fading strokes still look like a spinner when reduced motion freezes it on a single frame.

Examples

Sizes

The spinner is size-4 (16px) — the icon size used across the system — so it drops into buttons, inputs and menu items without adjustment. Scale it with size-* classes; it's an SVG, so it stays crisp at every size.

import { Spinner } from "@/components/ui/spinner"

export default function SpinnerSizes() {
  return (
    <div className="flex items-center gap-6">
      <Spinner className="size-3" />
      <Spinner />
      <Spinner className="size-5" />
      <Spinner className="size-6" />
      <Spinner className="size-8" />
    </div>
  )
}

Colors

The strokes use currentColor. Inherit the surrounding text color by default, or set one with a text-* class — text-muted-foreground for quiet, secondary work.

import { Spinner } from "@/components/ui/spinner"

export default function SpinnerColors() {
  return (
    <div className="flex items-center gap-6">
      <Spinner className="size-5" />
      <Spinner className="size-5 text-muted-foreground" />
      <Spinner className="size-5 text-brand" />
      <Spinner className="size-5 text-success-11" />
      <span className="flex size-10 items-center justify-center rounded-lg bg-primary">
        <Spinner className="size-5 text-primary-foreground" />
      </span>
    </div>
  )
}

In buttons

Prefer the Button's loading prop: it keeps the button's width and focus and sets aria-busy. Compose a spinner yourself only when the label should change while the action runs.

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerInButton() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      {/* Preferred: keeps width and focus, sets aria-busy. */}
      <Button loading>Save changes</Button>
      {/* Composition: when the label should change while working. */}
      <Button variant="outline" disabled>
        <Spinner data-icon="inline-start" aria-hidden="true" label="" />
        Generating report…
      </Button>
    </div>
  )
}
<Button variant="outline" disabled>
  <Spinner data-icon="inline-start" aria-hidden="true" label="" />
  Generating report…
</Button>

Loading a section

For a panel that is fetching data, center a spinner with a short sentence about what's happening. If you know the content's layout, a Skeleton is usually the better choice.

Crunching last month's numbers…

import { Spinner } from "@/components/ui/spinner"

export default function SpinnerSection() {
  return (
    <div
      aria-busy="true"
      className="flex h-44 w-full max-w-md flex-col items-center justify-center gap-3 rounded-xl border border-dashed bg-surface"
    >
      <Spinner
        className="size-5 text-muted-foreground"
        label="Loading analytics"
      />
      <p className="text-sm text-muted-foreground">
        Crunching last month&apos;s numbers…
      </p>
    </div>
  )
}

Guidelines

When to use

  • While a request triggered by the person is in flight: saving, sending, refreshing.
  • For background work tied to a specific control or row: Syncing… next to a file.

When not to use

  • For waits under about a second. Delay the spinner by ~200ms so fast responses never show it.
  • For long tasks with measurable progress — use Progress.
  • For content whose layout you know — use a Skeleton.

One spinner at a time

A page full of spinners says "everything is broken". Show the indicator where the person's attention is — the button they pressed, the row they changed — and keep the rest of the interface usable.

Uploading 3 of 12…

DoThe spinner sits next to the thing that's working, with a short label.
Don’tA spinner with no context leaves people guessing what they're waiting for.

Accessibility

  • A standalone spinner renders role="status" with aria-label from the label prop (default Loading, or its translation from LocaleProvider). Give it a specific label — Loading comments beats Loading.
  • When visible text already describes the wait, put role="status" on the wrapper and hide the spinner with aria-hidden="true", so the message isn't announced twice.
  • Inside a button, the spinner is decorative: the button's own label and aria-busy carry the meaning.
  • Under prefers-reduced-motion the rotation stops on a single frame. The fading strokes still communicate activity without movement.

API reference

Spinner

Renders an <svg> with data-slot="spinner". Accepts all SVG props.

PropTypeDefault
label

Accessible name, applied as aria-label. Defaults to the locale's loading message. Pass an empty string when the spinner is decorative: it is then hidden from assistive technology.

stringmessages.loading
className

Size (size-*) and color (text-*). Defaults to size-4 and currentColor.

stringNo default