Spinner
A turning loader icon, announced as a status.
shadcn's base-nova spinner with one change: it stops turning for a reader who has asked for less motion — by the OS setting or a [data-motion="reduced"] above it — where shadcn's turns regardless. A spin is a loop, so it stops rather than slows. It is lucide's Loader2 with role="status" and the name Loading. Give it a name of its own when it stands alone, saying what it waits on; hide it with aria-hidden when words beside it already say what is loading — a button's label, a status line, a sentence — so the reader is not told twice. Size it with a size class, or in em inside running text.
Install
npx shadcn@latest add @vibra/spinnerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
export default function SpinnerDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-6">
{/* The words say what is loading, so the spinner beside them is hidden:
left with its own "Loading", it would be read before them. */}
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Syncing 1,204 workspaces…
</p>
{/* Inside a button the label says it all, so the spinner is hidden from the name. */}
<Button disabled>
<Spinner data-icon="inline-start" aria-hidden="true" />
Saving
</Button>
</div>
)
}Sizes
One icon at four sizes, each captioned with the place it belongs, from a badge to a page.
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
// One icon at four sizes, each with the place it belongs: a badge, a button
// (the default, which is also the size for a line of text), a panel waiting for
// its content, and a page. Specimens, so each is hidden; its caption says it.
const SIZES = [
{ size: "size-3", use: "In a badge" },
{ size: "size-4", use: "In a button" },
{ size: "size-5", use: "In a panel" },
{ size: "size-8", use: "Over a page" },
]
export default function SpinnerSizes() {
return (
<ul aria-label="Spinner sizes" className="grid w-full max-w-md grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-4">
{SIZES.map(({ size, use }) => (
<li key={size} className="flex flex-col items-center gap-2 text-center">
<span className="flex h-8 items-center">
<Spinner aria-hidden="true" className={cn(size, "text-muted-foreground")} />
</span>
<span className="font-mono text-xs">{size}</span>
<span className="text-xs text-muted-foreground">{use}</span>
</li>
))}
</ul>
)
}With its own name
Nothing else stands in the panel, so the spinner is a status named for what it waits on, read once.
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
// The chart has not arrived and nothing else stands in the panel, so the
// spinner carries the words itself: a status named for what it waits on, never
// the bare default "Loading". The name does not change while it turns, so it
// is read once, and it leaves with the spinner when the chart takes its place.
export default function SpinnerLabelled() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Revenue by region</CardTitle>
<CardDescription>September, every plan</CardDescription>
</CardHeader>
<CardContent className="flex h-24 items-center justify-center">
<Spinner aria-label="Loading revenue by region" className="size-5 text-muted-foreground" />
</CardContent>
</Card>
)
}In a line of text
Sized in em and set on the text's line, at the end of sentences that already say what is happening.
import { Spinner } from "@/components/ui/spinner"
// Inside running text the spinner is sized in em and set on the text's line, so
// one class fits a 14px sentence and a 12px footnote alike. Each sentence says
// what is happening, so the spinner at its end is hidden.
const INLINE = "inline size-[1em] align-[-0.125em] text-muted-foreground"
export default function SpinnerInline() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<h3 className="text-lg font-semibold">Q3 board pack</h3>
<p role="status" className="text-sm">
Refreshing 12 charts from this morning's close <Spinner aria-hidden="true" className={INLINE} />
</p>
<p className="text-xs text-muted-foreground">
Revenue includes 38 invoices that are still being recalculated <Spinner aria-hidden="true" className={INLINE} />
</p>
</div>
)
}In a button
The label stays, out of sight, so the button keeps its width and its name; aria-busy says it is working.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
// While it saves, the label stays in the button — only out of sight — so the
// button keeps its width, the row beside it does not shift, and its name is
// still "Save changes"; aria-busy says it is working. The spinner is laid over
// the label's place, and the one status line says when it is done.
export default function SpinnerButton() {
const [state, setState] = React.useState<"idle" | "saving" | "saved">("idle")
const saving = state === "saving"
React.useEffect(() => {
if (!saving) return
const timer = window.setTimeout(() => setState("saved"), 1400)
return () => window.clearTimeout(timer)
}, [saving])
return (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-2">
<Button variant="outline">Discard</Button>
{/* focusableWhenDisabled: a natively disabled button would drop the
focus it was pressed with. */}
<Button
disabled={saving}
focusableWhenDisabled
aria-busy={saving || undefined}
onClick={() => setState("saving")}
>
<span className="grid place-items-center *:col-start-1 *:row-start-1">
<span className={cn(saving && "opacity-0")}>Save changes</span>
{saving ? <Spinner aria-hidden="true" /> : null}
</span>
</Button>
</div>
<p role="status" className="min-h-4 text-xs text-muted-foreground">
{saving ? "Saving the billing contact…" : state === "saved" ? "Billing contact saved." : null}
</p>
</div>
)
}A row at work
One source syncing in a list at rest: the spinner takes the place of the row's tick, beside words that say what it syncs.
import { ActivityIcon, CheckIcon, HandshakeIcon, ReceiptTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
const SOURCES = [
{ name: "Duesbrook", icon: ReceiptTextIcon, syncing: false, status: "Synced 4 min ago" },
{ name: "Closemere", icon: HandshakeIcon, syncing: true, status: "Syncing 1,204 deals…" },
{ name: "Clickbrook", icon: ActivityIcon, syncing: false, status: "Synced 12 min ago" },
]
// One row at work in a list that is otherwise at rest: the spinner takes the
// place of the row's tick, the words beside it say what it is doing, and the
// rest of the row, and every other row, stays readable and usable. A row that
// is already syncing has nothing to offer, so it shows no Sync now.
export default function SpinnerRow() {
return (
<ul aria-label="Connected sources" className="flex w-full max-w-md flex-col divide-y panel">
{SOURCES.map((source) => (
<li key={source.name} className="flex items-center gap-3 px-3 py-2.5">
<source.icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{source.name}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{source.syncing ? (
<Spinner aria-hidden="true" className="size-3" />
) : (
<CheckIcon aria-hidden="true" className="size-3 text-success" />
)}
<span className="truncate">{source.status}</span>
</span>
</div>
{source.syncing ? null : (
<Button variant="ghost" size="xs" aria-label={`Sync ${source.name} now`}>
Sync now
</Button>
)}
</li>
))}
</ul>
)
}The step at work
A setup that runs on its own: the running step shows the spinner, and every step says its state in words.
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
const STEPS = [
{ title: "Create the workspace", state: "done" },
{ title: "Import 1,204 customers", state: "running" },
{ title: "Build the default dashboards", state: "waiting" },
{ title: "Invite your team", state: "waiting" },
] as const
const SAID = { done: "done", running: "in progress", waiting: "waiting" }
// Setup runs on its own, one step at a time. The step at work shows a spinner
// where the others show a tick or an empty ring, and every step says its state
// in words for a screen reader; the spinner and the icons are decoration.
export default function SpinnerSteps() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<p className="text-sm font-medium">Setting up Northwind Analytics</p>
<ol aria-label="Setup steps" className="flex flex-col gap-2.5 text-sm">
{STEPS.map((step) => (
<li key={step.title} aria-current={step.state === "running" ? "step" : undefined} className="flex items-center gap-2.5">
<span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
{step.state === "done" ? <CheckIcon className="size-4 text-success" /> : null}
{step.state === "running" ? <Spinner aria-hidden="true" /> : null}
{step.state === "waiting" ? <span className="size-3 rounded-full ring-1 ring-input ring-inset" /> : null}
</span>
<span className={cn(step.state === "waiting" && "text-muted-foreground", step.state === "running" && "font-medium")}>
{step.title}
</span>
<span className="sr-only">, {SAID[step.state]}</span>
</li>
))}
</ol>
</div>
)
}Over a card
Refresh keeps the last figures under a veil, busy and inert; the line under the title says what is happening.
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
const ACCOUNTS = [
{ company: "Lumen Studio", mrr: 17_856 },
{ company: "Beacon Retail", mrr: 17_760 },
{ company: "Ironwood Studio", mrr: 17_280 },
]
// Refreshing keeps the last figures in place under a veil rather than blanking
// the card, so the reader keeps their bearings. While it works the list is busy
// and inert, the spinner on the veil is hidden, and the line under the title
// says what is happening, then when it last updated.
export default function SpinnerOverlay() {
const [refreshing, setRefreshing] = React.useState(false)
const [updated, setUpdated] = React.useState("Updated at 09:12")
React.useEffect(() => {
if (!refreshing) return
const timer = window.setTimeout(() => {
setRefreshing(false)
setUpdated("Updated just now")
}, 1600)
return () => window.clearTimeout(timer)
}, [refreshing])
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Top accounts by MRR</CardTitle>
<CardDescription role="status">{refreshing ? "Refreshing top accounts…" : updated}</CardDescription>
<CardAction>
<Button
variant="ghost"
size="icon-sm"
aria-label="Refresh top accounts"
disabled={refreshing}
focusableWhenDisabled
onClick={() => setRefreshing(true)}
>
<RefreshCwIcon aria-hidden="true" />
</Button>
</CardAction>
</CardHeader>
<CardContent className="relative">
<ol
aria-label="Top accounts"
aria-busy={refreshing}
inert={refreshing}
className={cn("flex flex-col gap-2 text-sm transition-opacity duration-(--duration-base)", refreshing && "opacity-40")}
>
{ACCOUNTS.map((account) => (
<li key={account.company} className="flex items-baseline justify-between gap-3">
<span className="truncate">{account.company}</span>
<span className="tabular-nums">{formatCurrency(account.mrr, "USD", { maximumFractionDigits: 0 })}</span>
</li>
))}
</ol>
{refreshing ? (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner aria-hidden="true" className="size-5" />
</div>
) : null}
</CardContent>
</Card>
)
}Loading more
A spinner row stands where the next orders will appear; then the focus moves to the first of them.
"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
const ORDERS = [
{ number: "ORD-100384", company: "Alder Robotics", total: 558 },
{ number: "ORD-100270", company: "Silverpine Systems", total: 5 },
{ number: "ORD-100149", company: "Northwind Health", total: 706 },
{ number: "ORD-100250", company: "Granite Retail", total: 39 },
{ number: "ORD-100037", company: "Northwind Studio", total: 367.98 },
{ number: "ORD-100219", company: "Beacon Retail", total: 176.98 },
{ number: "ORD-100302", company: "Kestrel Software", total: 149.98 },
]
const PAGE = 3
// The list grows at its end. While the next orders load, a row with a spinner
// stands where they will appear, the list is busy and the button keeps the
// focus; then the focus moves to the first new order, so a keyboard reader
// carries on where the list grew. One status line says what is happening.
export default function SpinnerLoadMore() {
const [shown, setShown] = React.useState(PAGE)
const [loading, setLoading] = React.useState(false)
const list = React.useRef<HTMLUListElement>(null)
const arrived = React.useRef<number | null>(null)
const next = Math.min(PAGE, ORDERS.length - shown)
React.useEffect(() => {
if (!loading) return
const timer = window.setTimeout(() => {
arrived.current = shown
setShown(shown + next)
setLoading(false)
}, 1200)
return () => window.clearTimeout(timer)
}, [loading, shown, next])
React.useEffect(() => {
if (arrived.current === null) return
list.current?.querySelectorAll("a")[arrived.current]?.focus()
arrived.current = null
}, [shown])
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<ul ref={list} aria-label="This week's orders" aria-busy={loading} className="flex flex-col divide-y text-sm">
{ORDERS.slice(0, shown).map((order) => (
<li key={order.number} className="flex items-baseline gap-3 py-2">
<a href={`/orders/${order.number}`} className="rounded-sm font-mono text-xs underline-offset-4 hover:underline focus-ring">
{order.number}
</a>
<span className="min-w-0 flex-1 truncate">{order.company}</span>
<span className="tabular-nums">{formatCurrency(order.total)}</span>
</li>
))}
{loading ? (
<li aria-hidden="true" className="flex items-center gap-2 py-2 text-muted-foreground">
<Spinner aria-hidden="true" />
Loading {next} more orders…
</li>
) : null}
</ul>
{shown < ORDERS.length ? (
<Button variant="outline" size="sm" className="self-start" disabled={loading} focusableWhenDisabled onClick={() => setLoading(true)}>
Show {next} more {next === 1 ? "order" : "orders"}
</Button>
) : null}
<p role="status" className="sr-only">
{loading ? `Loading ${next} more orders…` : `Showing ${shown} of ${ORDERS.length} orders.`}
</p>
</div>
)
}Held still when asked
The app's Reduce motion switch sets data-motion="reduced", which stops the spin as the OS setting does.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
// A settings preview for the app's own "Reduce motion" switch. It sets
// data-motion="reduced" on what it wraps, which stops the spinner as the
// reader's OS setting does: a spin is a loop, so it stops rather than slows.
// The export is running either way, and the words beside the spinner say so.
export default function SpinnerStill() {
const [still, setStill] = React.useState(false)
const id = React.useId()
return (
<div data-motion={still ? "reduced" : undefined} className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor={id}>Reduce motion</Label>
<p id={`${id}-hint`} className="text-sm text-muted-foreground">
Spinners hold still; the words beside them still say what is running.
</p>
</div>
<Switch id={id} checked={still} onCheckedChange={setStill} aria-describedby={`${id}-hint`} />
</div>
<p role="status" className="flex items-center gap-2 panel px-3 py-2.5 text-sm">
<Spinner aria-hidden="true" className="text-muted-foreground" />
Exporting 9,870 orders to Silomere…
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | "Loading" | The status's name. When the spinner stands alone, say what it waits on — "Loading revenue by region"; it is read once, as it appears. |
| aria-hidden | boolean | — | Set it when the words beside the spinner already say what is loading: inside a button, beside a status line, at the end of a sentence. |
| className | string | "size-4" | The size and colour: size-3 in a badge, size-4 in a button or a line, size-5 in a panel, size-8 over a page, size-[1em] inside text. |
Dependencies
Registry
npm
Source
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
// A spin is a loop, so no token can time it: it turns only for a reader
// who has not asked for less motion — by the OS setting or a
// [data-motion="reduced"] above it — and the status and its name still
// say it is working when it is still.
<Loader2Icon
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 motion-safe:not-in-data-[motion=reduced]:animate-spin", className)}
{...props}
/>
)
}
export { Spinner }