Skip to content

Spinner

A small indeterminate progress glyph for work that takes a moment.

Status
Stable
Level
Atom
Category
Feedback
Adoption
Not used yet
import { Spinner } from "@oration/canon/components/spinner";
packages/canon/src/components/spinner.tsx

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 useDelayedFlag so 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

A spinner answers the person who pressed something: it lives on that control or next to it. Data the page fetched on its own loads as a skeleton in its final shape.

The label carries the state

Keep a text label beside the spinner and change it to the present tense of the action (Syncing bills, Testing connection). The spinner alone says nothing about what is happening.

Anatomy#

  1. Arc. Lucide Loader2Icon, a three-quarter circle at the 1.75 stroke, drawn in currentColor so it takes the text color of its control.
  2. Box. 16px by default (size-4). Inside buttons the button sizes it: 16px, 14px at sm, 12px at xs.
  3. Name. role="status" and aria-label="Loading" by default. Pass aria-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.

12px
14px
16px, default
Slate Meta
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>    );}

In a button

Swap the leading icon for the spinner with the same data-icon="inline-start" and change the label to what is happening. Press it to run a 1.6 second sync.

import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function InButton() {    const [syncing, setSyncing] = React.useState(false);    React.useEffect(() => {        if (!syncing) return;        const id = window.setTimeout(() => {            setSyncing(false);            toast.add({                type: "success",                title: "QuickBooks synced",                description: "38 new bills from Northwind Freight and Halcyon.",            });        }, 1600);        return () => window.clearTimeout(id);    }, [syncing]);    return (        <Button            type="button"            variant="outline"            disabled={syncing}            onClick={() => setSyncing(true)}        >            {syncing ? (                <Spinner data-icon="inline-start" aria-hidden="true" />            ) : (                <RefreshCwIcon data-icon="inline-start" aria-hidden="true" />            )}            {syncing ? "Syncing bills" : "Sync bills"}        </Button>    );}

In an icon button

The spinner replaces the icon in the same square, and the accessible name changes with it. aria-disabled keeps focus on the button while it works.

First Midwest operating accountLast refreshed at 9:42 AM
import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function IconButton() {    const [syncing, setSyncing] = React.useState(false);    React.useEffect(() => {        if (!syncing) return;        const id = window.setTimeout(() => {            setSyncing(false);            toast.add({ type: "success", title: "Bank feed refreshed" });        }, 1400);        return () => window.clearTimeout(id);    }, [syncing]);    return (        <div className="flex w-full max-w-sm items-center gap-3 rounded-xl bg-card px-4 py-3 shadow-border">            <div className="flex min-w-0 flex-1 flex-col">                <span className="truncate text-13 font-medium text-foreground">                    First Midwest operating account                </span>                <span className="text-xs text-muted-foreground">                    {syncing ? "Refreshing" : "Last refreshed at 9:42 AM"}                </span>            </div>            <Tooltip>                <TooltipTrigger                    render={                        <Button                            type="button"                            variant="ghost"                            size="icon-sm"                            aria-label={                                syncing                                    ? "Refreshing bank feed"                                    : "Refresh bank feed"                            }                            aria-disabled={syncing || undefined}                            onClick={() => {                                if (!syncing) setSyncing(true);                            }}                        />                    }                >                    {syncing ? (                        <Spinner aria-hidden="true" />                    ) : (                        <RefreshCwIcon aria-hidden="true" />                    )}                </TooltipTrigger>                <TooltipContent>Refresh bank feed</TooltipContent>            </Tooltip>        </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#

States
StateTreatment
Spinninganimate-spin: one full turn per second, linear, for as long as it is mounted.
In a disabled controlTakes the control's 50% opacity. Prefer aria-disabled or Pending button so the control keeps focus.
Reduced motionKeeps 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, role and className, 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 once pending has stayed true for 200ms. Gate the spinner on it.

Do and don't#

Suppliers

Do. Load content as a skeleton in its final shape, and keep the spinner for actions.
Don't. Center a spinner in an empty card while its rows load. The card jumps when they arrive.
Do. Keep a label beside the spinner that says what is happening: Saving supplier.
Don't. Replace the whole label with a spinner. The button shrinks and nothing names the work.
Do. Hold the spinner back 200ms so fast work finishes without a flash.
Don't. Show a spinner for every request, however quick. A 100ms flicker reads as a glitch.

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" with aria-label="Loading". Inside a labelled button that label joins the button's name ("Loading Testing connection"), so pass aria-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-disabled keeps focus in place; native disabled drops it to the body.
  • Icon buttons that swap to a spinner update their aria-label too: 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#

Design tokens
TokenUsed for
currentColorArc color, from the control's text
animate-spinOne turn per second, linear, infinite
size-416px default box
svg.lucideThe 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).

Props of Spinner
PropTypeDefaultDescription
classNamestring"size-4 animate-spin"Merged after the defaults. Set size and color here: size-3.5, text-muted-foreground.
aria-labelstring"Loading"The status name. Override it with what is loading, or hide the spinner when a label says it.
roleAriaRole"status"Exposed as a status. Override only with good reason.
aria-hiddenbooleanNo defaultSet inside labelled buttons and inline hints, where the text already speaks.
data-icon"inline-start" | "inline-end"No defaultInside 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.