Spinner
A loading spinner with 3 tones × 4 sizes, announced to screen readers via role=status and a configurable label.
import { Spinner } from "@/components/ui/spinner"
export default function SpinnerDemo() {
return (
<div className="flex items-center gap-4">
<Spinner size="sm" />
<Spinner tone="brand" />
<Spinner size="lg" tone="muted" />
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/spinnerDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1 lucide-react@^1.23.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/spinner.tsximport type * as React from "react"
import { Loader2 } from "lucide-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const spinnerVariants = cva("animate-spin", {
variants: {
tone: {
current: "text-current",
brand: "text-brand",
muted: "text-muted-foreground",
},
size: {
sm: "size-4",
md: "size-5",
lg: "size-6",
xl: "size-8",
},
},
defaultVariants: {
tone: "current",
size: "md",
},
})
interface SpinnerProps
extends React.ComponentProps<"svg">,
VariantProps<typeof spinnerVariants> {
label?: string
}
function Spinner({ className, tone, size, label = "Loading", ...props }: SpinnerProps) {
return (
<Loader2
data-slot="spinner"
role="status"
aria-label={label}
className={cn(spinnerVariants({ tone, size, className }))}
{...props}
/>
)
}
export { Spinner, spinnerVariants, type SpinnerProps }Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import { Spinner } from "@/components/ui/spinner"
<Spinner tone="brand" size="lg" />Both axes are optional - the default is a medium spinner that inherits the current text color.
Examples
Tones
tone="current" inherits the text color, so the spinner matches whatever it sits inside - for example a Button's label color.
Sizes
With text
The default tone follows the wrapper's text color, and the label prop replaces the default "Loading" announcement.
Props
Spinner also accepts every native <svg> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
tone | "current" | "brand" | "muted" | "current" | Color of the spinner. current inherits the surrounding text color, so it matches whatever context it sits in. |
size | "sm" | "md" | "lg" | "xl" | "md" | Diameter of the spinner, from size-4 to size-8. |
label | string | "Loading" | Accessible name announced by screen readers via aria-label. |
ai2 Spinner: a loading indicator for React with tones and sizes
The ai2 Spinner is a shadcn-compatible spinner component for React, styled with Tailwind CSS v4. It renders a rotating loader icon for indeterminate waits: pending buttons, background fetches, page transitions and anywhere a skeleton has no layout to mirror. It comes with 3 tones and 4 sizes from a single cva, and it announces itself to screen readers out of the box.
It ships through the shadcn registry format, so one CLI command, an MCP agent, or a copy-paste puts the source in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Spinner?
It is a single part: Spinner, the lucide Loader2 SVG spun with Tailwind's animate-spin utility. There is no radix primitive and no JavaScript animation. It carries role="status" and an aria-label that defaults to "Loading" and is configurable via the label prop.
The cva defines two axes: tone (current, brand, muted) and size (sm, md, lg, xl, from size-4 to size-8). The default tone is current, which inherits the surrounding text color, so a spinner dropped inside a button, an alert or a colored wrapper matches its context with zero configuration.
Why use it
- Honest accessibility built in: The SVG ships with role="status" and an aria-label, so screen readers announce the loading state without any wrapper markup. The label prop swaps the announcement for context-specific text like "Deploying".
- Context-matching color: tone="current" inherits the text color of whatever contains the spinner, so one component works inside buttons, badges, alerts and plain text without per-case overrides.
- Four sizes for real layouts: sm (size-4) fits inline text and buttons, md is the default, lg suits cards and xl suits page-level loading states.
- Zero-dependency animation: The rotation is Tailwind's animate-spin utility, pure CSS on a lucide icon that shadcn projects already have. No animation library is added.
- Agent-readable metadata: The registry item states the tone and size axes in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its lucide-react dependency and the @ai2/tokens theme to your project.
- 3 tones from the theme: current inherits the surrounding text color, brand uses the brand token, muted uses the muted-foreground token for quiet background activity.
- 4 sizes: sm, md, lg and xl map to size-4 through size-8, covering inline, control, card and page-level loading states.
- Configurable announcement: The label prop sets the aria-label, so screen readers can hear "Deploying" or "Saving" instead of a generic "Loading".
- Exported variants: spinnerVariants is exported alongside the component, so you can apply the exact same tone and size classes to a custom SVG if you swap the icon.
- TypeScript source: The file you install is typed end to end and forwards every native svg prop, so autocomplete covers the full API.
Production tips
- Pair it with visible text when possible: A spinner next to a short status line like "Deploying to production" informs sighted users and screen reader users alike. Keep the label prop in sync with the visible text.
- Prefer skeletons for known layouts: If you know the shape of the incoming content, a skeleton communicates more. Reserve the spinner for indeterminate operations like submits and background work.
- Match size to the container: Use sm inside buttons and table rows so the line height does not jump, and xl only for full-page or full-panel waits.
- Let tone="current" do the work: Instead of forcing a color, place the spinner inside a wrapper that already has the right text color, for example text-success while a deploy is healthy.
- Avoid spinner farms: Several spinners pulsing in one view read as chaos. Lift the loading state to the highest reasonable container and show one indicator.
Works with the rest of ai2
The spinner composes naturally with the rest of the registry. Show pending actions inside an ai2 Button, indicate background activity next to an ai2 Badge in a table row, or hold a panel open while a ai2 Dialog form submits.
For content with a known shape, reach for the ai2 Skeleton instead, and when you can report completion percentage, use ai2 Progress. Everything shares one token source, so loading states stay visually consistent in both light and dark mode.