Loading…
Loading…
An animated indicator showing that content or a process is loading.
The Spinner (also called a loading spinner, activity indicator, or throbber) is a feedback component that communicates an ongoing, indeterminate process — something is happening, but we can't tell you exactly when it will finish. It's the UI equivalent of "please hold."
Spinners are the most common loading pattern on the web, and also the most overused. A spinner tells the user absolutely nothing about progress, duration, or what's being loaded. It's a last resort when you genuinely have no progress data. When you do have progress information, a Progress Bar is always superior. When you know the shape of the content being loaded, a Skeleton provides better perceived performance.
Despite this, spinners remain indispensable for short-duration, unpredictable operations: submitting a form, checking authentication, fetching a small API response, or processing a payment.
When to use a Spinner:
When NOT to use a Spinner:
Design your spinner's animation curve with the Animation & Easing Tool. Use the Contrast Checker to verify the spinner is visible against its background, and explore the Loader Generator for ready-made spinner patterns.
| Variant | Visual Description | Common In |
|---|---|---|
| Circular / Ring | An arc that rotates continuously. Usually 270° of a circle. | Material Design, most modern systems |
| Dots | 3–4 dots pulsing, bouncing, or fading in sequence. | iOS-style, chat "typing" indicators |
| Bar / Line | Short bars rotating or pulsing around a center point. | Classic "activity indicator" (macOS, iOS) |
| Ring with Gap | Full circle border with one segment highlighted, rotating. | Ant Design, Chakra UI |
| Dual Ring | Two concentric rings rotating in opposite directions. | High-tech, dashboard UIs |
| Pulse | A single shape (circle or dot) that scales up and fades out repeatedly. | Minimal designs, skeleton transitions |
| Bounce | Three dots or elements bouncing in sequence with staggered timing. | Friendly, consumer-facing products |
| Variant | Placement | Use Case |
|---|---|---|
| Inline | Inside a text flow or next to a label. 16–20px. | "Loading results…" with spinner next to text |
| Button | Inside a Button, replacing or preceding the label. | Form submission, "Save" actions |
| Overlay | Centered over a region or the full page with a semi-transparent backdrop. | Full-page loading, modal content loading |
| Card / Section | Centered within a Card or content area. | Individual content regions refreshing |
| Input | Inside a Text Input or Select field. | Async validation, search-as-you-type |
| Size | Diameter | Use Case |
|---|---|---|
| xs | 12–16px | Inside inputs, inline text, table cells |
| sm | 20–24px | Inside buttons, badges, compact UI |
| md | 32–40px | Default standalone, card loading |
| lg | 48–64px | Section or page-level loading |
| xl | 80–120px | Full-page overlay, splash screens |
| Property | Type | Default | Description |
|---|---|---|---|
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number | 'md' | Spinner diameter. Number for exact pixel size. |
variant | 'circular' | 'dots' | 'bars' | 'pulse' | 'circular' | Animation style |
color | string | 'currentColor' | Spinner color. Inherits text color by default. |
trackColor | string | 'transparent' | Color of the inactive ring portion |
thickness | number | 3 | Stroke width of the ring (for circular variant) |
speed | 'slow' | 'normal' | 'fast' | 'normal' | Animation duration (slow: 1.2s, normal: 0.8s, fast: 0.5s) |
label | string | — | Visible text displayed alongside the spinner |
delay | number | 0 | Milliseconds to wait before showing the spinner (prevents flash for fast loads) |
overlay | boolean | false | Renders a semi-transparent backdrop behind the spinner |
aria-label | string | 'Loading' | Accessible label. Always provide a meaningful description. |
Important: The delay prop. Always add a delay of 300–500ms before showing a spinner. If the operation completes within that window, the user never sees the spinner (which is ideal — flashing a spinner for 100ms is disorienting). This single prop eliminates the most common spinner UX problem.
Spinners are visually simple but touch color, motion, and sizing tokens. For token architecture fundamentals, see the Design Tokens Complete Guide.
| Token Category | Token Example | Spinner Usage |
|---|---|---|
| Color | currentColor / --color-primary-500 | Spinner stroke or fill color |
| Color – Track | --color-neutral-200 | Inactive portion of ring spinners |
| Color – Overlay BG | --color-overlay (rgba(0,0,0,0.4)) | Background when overlay={true} |
| Size | --size-4 / --size-8 / --size-12 | Spinner diameter per size variant |
| Border Width | --border-width-2 / --border-width-3 | Ring stroke thickness |
| Animation Duration | --duration-slow (1.2s) | Rotation cycle duration |
| Animation Timing | --ease-linear | Rotation uses linear timing; acceleration on the arc uses ease-in-out. Preview with Animation & Easing Tool. |
| Z-Index | --z-overlay | Z-index for overlay spinners |
| Spacing | --space-2 / --space-3 | Gap between spinner and label text |
Spinners are stateless animations — they don't have hover, focus, or active states like interactive components. Their "state" is defined by their presence and context:
| State | Behavior | Implementation |
|---|---|---|
| Hidden | Spinner is not rendered. Operation hasn't started or completed within delay window. | Conditionally render. Don't use visibility: hidden or opacity: 0 — fully remove from DOM and accessibility tree. |
| Delayed | Timer running but spinner not yet visible. | Use setTimeout or CSS animation-delay to prevent flash-of-spinner for fast operations. |
| Spinning | Actively animating. Accessible label being announced. | Render with role="status" and aria-label. Set aria-busy="true" on the content region being loaded. |
| With Label | Spinning alongside a text message ("Loading your dashboard…"). | Visually and semantically pair the label with the spinner. |
| Overlay | Spinner centered over a backdrop that dims and blocks the underlying content. | Backdrop should trap pointer events (pointer-events: all). Content behind should have aria-busy="true". |
| Completed | Spinner transitions to a success checkmark or simply disappears. | Animate out (fade/scale) rather than disappearing abruptly. Optional: morph into a ✓ icon for 1–2 seconds. |
For the best user experience, follow this lifecycle:
This prevents flash-of-spinner and ensures smooth transitions. Preview fade animations with the Animation & Easing Tool.
Spinners present a unique accessibility challenge: they're purely visual animations, so without proper ARIA semantics, screen reader users have no idea something is loading. Verify spinner visibility with the Contrast Checker.
<!-- Standalone spinner with label -->
<div role="status" aria-label="Loading search results">
<svg class="spinner" aria-hidden="true"><!-- animated ring --></svg>
<span class="sr-only">Loading search results</span>
</div>
<!-- Content region with spinner -->
<div aria-busy="true" aria-describedby="loading-msg">
<div role="status" id="loading-msg">
<svg class="spinner" aria-hidden="true"></svg>
Loading your dashboard…
</div>
</div>
Key points:
role="status" (implicit aria-live="polite") so the loading message is announced without interrupting the useraria-hidden="true" — screen readers should read the text, not try to describe the animationaria-busy="true" on the container whose content is loading — this tells AT that the region is updating| Criterion | Level | Requirement for Spinners |
|---|---|---|
| 1.1.1 Non-text Content (A) | A | The visual spinner must have a text alternative. Use aria-label or visible label text. |
| 1.3.1 Info and Relationships (A) | A | Use role="status" to convey that this element communicates a loading state. |
| 1.4.1 Use of Color (A) | A | Don't rely on the spinner's color alone to communicate meaning. Pair with text labels. |
| 1.4.11 Non-text Contrast (AA) | AA | The spinner's visible arcs/dots must have at least 3:1 contrast against their background. A light gray spinner on white fails. Use the Contrast Checker. |
| 2.2.1 Timing Adjustable (A) | A | If a spinner is tied to a timeout (e.g., "Request will time out in 30 seconds"), provide a way to extend the time. |
| 2.3.1 Three Flashes or Below Threshold (A) | A | Spinner animations must not flash more than 3 times per second. Most rotation-based spinners naturally comply; be careful with rapid dot/pulse animations. |
| 4.1.3 Status Messages (AA) | AA | Loading state changes must be programmatically determinable. role="status" satisfies this — the content is announced as a live region update. |
Some users experience motion sickness or distraction from continuous animations. Respect the prefers-reduced-motion media query:
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
/* Show a static icon or pulsing opacity instead */
opacity: 0.7;
}
}
This is particularly important for large overlay spinners that dominate the viewport. WCAG 2.3.3 (Animation from Interactions, AAA) recommends this approach.
aria-busy="true" on the content region being loaded, not on the spinner itself.prefers-reduced-motion. Replace continuous rotation with a gentle pulse or static indicator.currentColor for the spinner color so it automatically adapts to its text context (dark text = dark spinner, light text = light spinner).Spinners should be lightweight:
transform: rotate() not border-color animations for GPU acceleration<!-- Basic CSS Spinner -->
<div role="status" aria-label="Loading">
<div class="spinner" aria-hidden="true"></div>
<span class="sr-only">Loading</span>
</div>
<!-- Spinner with visible label -->
<div class="spinner-container" role="status">
<div class="spinner" aria-hidden="true"></div>
<span class="spinner-label">Loading search results…</span>
</div>
<!-- Overlay Spinner -->
<div class="spinner-overlay" role="status" aria-label="Loading page content">
<div class="spinner spinner--lg" aria-hidden="true"></div>
</div>
<!-- Button with Spinner -->
<button class="btn btn-primary" disabled aria-busy="true">
<div class="spinner spinner--xs" aria-hidden="true"></div>
Submitting…
</button>
<style>
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-neutral-200);
border-top-color: var(--color-primary-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner--xs { width: 16px; height: 16px; border-width: 2px; }
.spinner--lg { width: 64px; height: 64px; border-width: 4px; }
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner-container {
display: flex;
align-items: center;
gap: 8px;
}
.spinner-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
z-index: 9999;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-color: var(--color-primary-500);
opacity: 0.7;
}
}
</style>import { forwardRef, useEffect, useState } from "react";
interface SpinnerProps {
size?: "xs" | "sm" | "md" | "lg" | "xl" | number;
color?: string;
trackColor?: string;
thickness?: number;
speed?: "slow" | "normal" | "fast";
label?: string;
delay?: number;
overlay?: boolean;
className?: string;
}
const sizeMap = { xs: 16, sm: 24, md: 32, lg: 48, xl: 80 };
const speedMap = { slow: "1.2s", normal: "0.8s", fast: "0.5s" };
export const Spinner = forwardRef<HTMLDivElement, SpinnerProps>(
(
{
size = "md",
color = "currentColor",
trackColor = "var(--color-neutral-200)",
thickness = 3,
speed = "normal",
label,
delay = 0,
overlay = false,
className,
...props
},
ref
) => {
const [visible, setVisible] = useState(delay === 0);
useEffect(() => {
if (delay > 0) {
const timer = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(timer);
}
}, [delay]);
if (!visible) return null;
const d = typeof size === "number" ? size : sizeMap[size];
const spinner = (
<div
ref={ref}
role="status"
aria-label={label ?? "Loading"}
className={className}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
{...props}
>
<div
aria-hidden="true"
style={{
width: d,
height: d,
border: `${thickness}px solid ${trackColor}`,
borderTopColor: color,
borderRadius: "50%",
animation: `spin ${speedMap[speed]} linear infinite`,
}}
/>
{label && <span>{label}</span>}
</div>
);
if (overlay) {
return (
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.4)",
zIndex: 9999,
}}
>
{spinner}
</div>
);
}
return spinner;
}
);
Spinner.displayName = "Spinner";Material Design 3 uses the CircularProgressIndicator component for both spinners and circular progress bars. In indeterminate mode, Material's animation is distinctive: the arc length oscillates between short and long while simultaneously rotating, creating a "breathing" effect that feels more organic than a simple rotation. Material also specifies exact animation curves: the rotation uses linear timing, while the arc sweep uses a custom cubic-bezier. This is a great example to study in the Animation & Easing Tool.
Apple's Human Interface Guidelines call this an "Activity Indicator." On iOS, it's the iconic set of 8 or 12 rounded bars arranged in a circle, each fading in sequence. This design is intentionally ambiguous about direction and speed — Apple's philosophy is that indeterminate loading should feel calm, not urgent. On macOS, the "spinning beach ball" cursor is the system-level equivalent (and universally dreaded).
Ant Design provides a Spin component that wraps content, automatically adding a blur/dim effect and centering the spinner over the wrapped region. It supports tip for label text, size (small/default/large), and a delay prop measured in milliseconds. The indicator prop lets you replace the default spinner with any custom animation — a useful escape hatch.
Chakra UI's Spinner is a styled <div> using border and border-top-color with a CSS rotation animation — the simplest possible implementation. It supports emptyColor (track), color (active arc), thickness, speed, and size. It uses role="status" with a visually hidden "Loading…" text for accessibility.
Shadcn/ui provides a minimal SVG-based spinner using Lucide's Loader2 icon with a CSS rotation animation. It's just an icon with animate-spin — about as simple as a spinner can get.
Bootstrap uses a border-based spinner (.spinner-border) and a "growing" variant (.spinner-grow) that pulses. It provides .spinner-border-sm for button contexts and uses role="status" with .visually-hidden text.
For custom spinner patterns with optimized CSS animations, explore the Loader Generator. Preview animation timing functions in the Animation & Easing Tool.