Spinner
An indeterminate activity indicator for short waits when the system is working and the result is on its way.
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.jsonUsage
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'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…
Accessibility
- A standalone spinner renders
role="status"witharia-labelfrom thelabelprop (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 witharia-hidden="true", so the message isn't announced twice. - Inside a button, the spinner is decorative: the button's own label and
aria-busycarry the meaning. - Under
prefers-reduced-motionthe 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.