Spinner
A small indeterminate progress glyph for work that takes a moment.
Connect Twilio
We place a short test call before saving, so supplier calls never reach a dead number.
import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { PlugIcon } from "lucide-react";import * as React from "react";export function Hero() { const [testing, setTesting] = React.useState(false); React.useEffect(() => { if (!testing) return; const id = window.setTimeout(() => { setTesting(false); toast.add({ type: "success", title: "Twilio connected", description: "The test call to +1 312 555 0148 answered in 412 ms.", }); }, 1800); return () => window.clearTimeout(id); }, [testing]); return ( <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"> <div className="flex flex-col gap-1 p-4"> <p className="text-base leading-none font-medium text-foreground"> Connect Twilio </p> <p className="text-sm text-muted-foreground"> We place a short test call before saving, so supplier calls never reach a dead number. </p> </div> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3"> <Button type="button" variant="ghost" disabled={testing} onClick={() => toast.add({ title: "Twilio not connected" })} > Cancel </Button> <Button type="button" disabled={testing} onClick={() => setTesting(true)} > {testing ? ( <Spinner data-icon="inline-start" aria-hidden="true" /> ) : ( <PlugIcon data-icon="inline-start" aria-hidden="true" /> )} {testing ? "Testing connection" : "Test connection"} </Button> </div> </div> );}Usage#
Spinner is a 16px rotating arc for work a person just started and is waiting on: testing a connection, syncing bills, checking a URL. It sits inside or beside the control that started the work, in the icon slot, while the label says what is happening. It is not a content loader. Lists, tables and cards load as a Skeleton in their final shape, and a spinner parked in an empty box is the most common misuse.
When to use
- In the leading icon slot of a button while its action runs, marked
data-icon="inline-start", with the label changed to what is happening: Testing connection. - In place of the icon on an icon button while it works, such as refreshing a bank feed.
- Beside a short inline status line, such as Checking availability under a URL field.
- Only after a short delay. Hold it back 200ms with
useDelayedFlagso fast work never flashes a spinner.
When not to use
- For content that is loading. Show bones in the final layout instead. Use Skeleton
- For a submit button whose width must not change while it works. Use Pending button
- For an AI model thinking or drafting, where elapsed time matters. Use AI loader
- For work with a known amount done, such as an import at 62%. Use Progress
- For a live state such as a running workflow or a call in progress. That is a labelled status, not a wait. Use Status label
Spinners belong to actions
The label carries the state
Anatomy#
- Arc. Lucide
Loader2Icon, a three-quarter circle at the 1.75 stroke, drawn incurrentColorso it takes the text color of its control. - Box. 16px by default (
size-4). Inside buttons the button sizes it: 16px, 14px atsm, 12px atxs. - Name.
role="status"andaria-label="Loading"by default. Passaria-hidden="true"when a visible label already says what is happening.
Examples#
Sizes and color
16px by default; 14px beside 13px text and 12px in extra-small controls. It draws in currentColor, so it matches the text it sits with.
import { Spinner } from "@oration/canon/components/spinner";export function Sizes() { return ( <div className="flex items-end gap-10"> <div className="flex flex-col items-center gap-3"> <Spinner className="size-3" /> <span className="text-xs text-muted-foreground tabular-nums"> 12px </span> </div> <div className="flex flex-col items-center gap-3"> <Spinner className="size-3.5" /> <span className="text-xs text-muted-foreground tabular-nums"> 14px </span> </div> <div className="flex flex-col items-center gap-3"> <Spinner /> <span className="text-xs text-muted-foreground tabular-nums"> 16px, default </span> </div> <div className="flex flex-col items-center gap-3"> <Spinner className="text-muted-foreground" /> <span className="text-xs text-muted-foreground"> Slate Meta </span> </div> </div> );}Inline status
A 14px spinner leads a status line under a field while it checks, then gives way to the result. Type northwind to see a conflict.
cedarline-ap is available
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Spinner } from "@oration/canon/components/spinner";import { CheckIcon, XIcon } from "lucide-react";import * as React from "react";export function InlineStatus() { const id = React.useId(); const [slug, setSlug] = React.useState("cedarline-ap"); const [status, setStatus] = React.useState< "idle" | "checking" | "available" | "taken" >("available"); React.useEffect(() => { if (status !== "checking") return; const timer = window.setTimeout(() => { setStatus(slug.trim() === "northwind" ? "taken" : "available"); }, 900); return () => window.clearTimeout(timer); }, [status, slug]); return ( <div className="flex w-full max-w-sm flex-col gap-1.5"> <Label htmlFor={id}>Workspace URL</Label> <Input id={id} value={slug} aria-describedby={`${id}-hint`} aria-invalid={status === "taken" || undefined} onChange={(event) => { setSlug(event.target.value); setStatus(event.target.value.trim() ? "checking" : "idle"); }} /> <p id={`${id}-hint`} aria-live="polite" className="flex min-h-5 items-center gap-1.5 text-13 text-muted-foreground" > {status === "checking" ? ( <> <Spinner aria-hidden="true" className="size-3.5" /> Checking availability </> ) : status === "available" ? ( <> <CheckIcon aria-hidden="true" className="size-3.5 text-success" /> {slug.trim()} is available </> ) : status === "taken" ? ( <> <XIcon aria-hidden="true" className="size-3.5 text-destructive" /> {slug.trim()} is already in use. Try another name. </> ) : ( "Letters, numbers and hyphens. Try typing northwind." )} </p> </div> );}Hold it back
useDelayedFlag(pending, 200) shows the spinner only once work has run for 200ms. Refresh balance finishes in 120ms and never shows one; Pull statement takes 1.6 seconds.
import { Button } from "@oration/canon/components/button";import { useDelayedFlag } from "@oration/canon/components/data-state";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function DelayedSpinner() { const [pending, setPending] = React.useState< null | "balance" | "statement" >(null); const showBalance = useDelayedFlag(pending === "balance", 200); const showStatement = useDelayedFlag(pending === "statement", 200); React.useEffect(() => { if (!pending) return; const wait = pending === "balance" ? 120 : 1600; const id = window.setTimeout(() => { toast.add({ type: "success", title: pending === "balance" ? "Balance refreshed" : "September statement ready", description: pending === "balance" ? "Finished in 120 ms, so no spinner appeared." : "Took 1.6 s, so the spinner showed after 200 ms.", }); setPending(null); }, wait); return () => window.clearTimeout(id); }, [pending]); return ( <> <Button type="button" variant="outline" disabled={pending !== null} onClick={() => setPending("balance")} > {showBalance ? ( <Spinner data-icon="inline-start" aria-hidden="true" /> ) : null} Refresh balance </Button> <Button type="button" variant="outline" disabled={pending !== null} onClick={() => setPending("statement")} > {showStatement ? ( <Spinner data-icon="inline-start" aria-hidden="true" /> ) : null} Pull statement </Button> </> );}States#
| State | Treatment |
|---|---|
| Spinning | animate-spin: one full turn per second, linear, for as long as it is mounted. |
| In a disabled control | Takes the control's 50% opacity. Prefer aria-disabled or Pending button so the control keeps focus. |
| Reduced motion | Keeps rotating. It is the one indicator that something is still working, so it is treated as essential motion. |
Behavior#
- Renders the SVG directly; all props, including
aria-label,roleandclassName, spread onto it after the defaults, so each one can be overridden. - Mounting it starts the rotation; unmount it or swap it back to the icon when the work finishes. It has no paused or done state of its own.
- Inside a Button,
data-icon="inline-start"tightens the leading padding by 2px exactly like a normal icon, so the swap from icon to spinner doesn't move the label. - Changing the label changes the button's width. When the width must hold, use Pending button, which fades the label and centers the spinner over it.
useDelayedFlag(pending, 200)from Data state returns true only oncependinghas stayed true for 200ms. Gate the spinner on it.
Do and don't#
Suppliers
Content#
- Change the label to the present participle of the action: Test connection becomes Testing connection, Sync bills becomes Syncing bills.
- Name the object when there are several on screen: Refreshing bank feed in the accessible name, even if the tooltip says Refresh.
- When the work finishes, confirm it with a toast or an inline result (cedarline-ap is available), not by the spinner simply disappearing.
- Don't write Please wait or Loading… next to a spinner in a button. The verb already says it.
Accessibility#
- By default the SVG is
role="status"witharia-label="Loading". Inside a labelled button that label joins the button's name ("Loading Testing connection"), so passaria-hidden="true"there and let the changed label speak. - A newly mounted status element is not reliably announced. For waits the person should hear about, update the text of a live region that already exists, as the URL check does with
aria-live="polite". - Keep the control focusable while it works.
aria-disabledkeeps focus in place; nativedisableddrops it to the body. - Icon buttons that swap to a spinner update their
aria-labeltoo: Refresh bank feed becomes Refreshing bank feed. - The spinner keeps turning under reduced motion because it is the only sign the work is still running. It is small, slow and never flashes.
Design tokens#
| Token | Used for |
|---|---|
currentColor | Arc color, from the control's text |
animate-spin | One turn per second, linear, infinite |
size-4 | 16px default box |
svg.lucide | The global 1.75px stroke every lucide icon shares |
API reference#
Spinner
Lucide Loader2Icon with a spin and a default name.
Other props spread onto <svg> (lucide icon props).
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | "size-4 animate-spin" | Merged after the defaults. Set size and color here: size-3.5, text-muted-foreground. |
aria-label | string | "Loading" | The status name. Override it with what is loading, or hide the spinner when a label says it. |
role | AriaRole | "status" | Exposed as a status. Override only with good reason. |
aria-hidden | boolean | No default | Set inside labelled buttons and inline hints, where the text already speaks. |
data-icon | "inline-start" | "inline-end" | No default | Inside a Button, tightens the padding on that side like any icon. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The default role="status" and aria-label="Loading" add "Loading" to the name of any button it sits in. Most call sites (<Spinner data-icon="inline-start" /> in about a dozen dialogs and settings rows) don't pass aria-hidden.
Pending button renders the spinner with its defaults over a label that is only faded to 0 opacity, so its name reads as the label plus "Loading".
There is no built-in delay. Every caller has to gate the spinner with useDelayedFlag itself, and most don't.