Skip to content

Spinner

An animated indicator showing that content or a process is loading.

  • Loading Spinner
  • Activity Indicator

Overview

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:

  • Short operations (1–5 seconds) where progress can't be measured
  • Button loading states (submitting a form, processing payment)
  • Inline loading within a small region (refreshing a single card, loading a dropdown's options)
  • Overlay loading when the entire page or modal is blocked
  • Lazy-loading components before their skeleton shape is known

When NOT to use a Spinner:

  • When you have quantifiable progress — use a Progress Bar
  • When you know the layout of incoming content — use a Skeleton
  • For full-page initial loads — use a skeleton screen for better perceived performance
  • For operations under 300ms — showing and immediately hiding a spinner is worse than no indicator at all. Apply a delay.
  • For long operations (> 10 seconds) — spinners without context create anxiety. Add a message, use a progress bar, or show a skeleton.

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.

Variants

Animation Variants

VariantVisual DescriptionCommon In
Circular / RingAn arc that rotates continuously. Usually 270° of a circle.Material Design, most modern systems
Dots3–4 dots pulsing, bouncing, or fading in sequence.iOS-style, chat "typing" indicators
Bar / LineShort bars rotating or pulsing around a center point.Classic "activity indicator" (macOS, iOS)
Ring with GapFull circle border with one segment highlighted, rotating.Ant Design, Chakra UI
Dual RingTwo concentric rings rotating in opposite directions.High-tech, dashboard UIs
PulseA single shape (circle or dot) that scales up and fades out repeatedly.Minimal designs, skeleton transitions
BounceThree dots or elements bouncing in sequence with staggered timing.Friendly, consumer-facing products

Context Variants

VariantPlacementUse Case
InlineInside a text flow or next to a label. 16–20px."Loading results…" with spinner next to text
ButtonInside a Button, replacing or preceding the label.Form submission, "Save" actions
OverlayCentered over a region or the full page with a semi-transparent backdrop.Full-page loading, modal content loading
Card / SectionCentered within a Card or content area.Individual content regions refreshing
InputInside a Text Input or Select field.Async validation, search-as-you-type

Size Variants

SizeDiameterUse Case
xs12–16pxInside inputs, inline text, table cells
sm20–24pxInside buttons, badges, compact UI
md32–40pxDefault standalone, card loading
lg48–64pxSection or page-level loading
xl80–120pxFull-page overlay, splash screens

Properties

Spinner Properties

PropertyTypeDefaultDescription
size'xs' | 'sm' | 'md' | 'lg' | 'xl' | number'md'Spinner diameter. Number for exact pixel size.
variant'circular' | 'dots' | 'bars' | 'pulse''circular'Animation style
colorstring'currentColor'Spinner color. Inherits text color by default.
trackColorstring'transparent'Color of the inactive ring portion
thicknessnumber3Stroke width of the ring (for circular variant)
speed'slow' | 'normal' | 'fast''normal'Animation duration (slow: 1.2s, normal: 0.8s, fast: 0.5s)
labelstring—Visible text displayed alongside the spinner
delaynumber0Milliseconds to wait before showing the spinner (prevents flash for fast loads)
overlaybooleanfalseRenders a semi-transparent backdrop behind the spinner
aria-labelstring'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.

Token Mappings

Design Token Mappings

Spinners are visually simple but touch color, motion, and sizing tokens. For token architecture fundamentals, see the Design Tokens Complete Guide.

Token CategoryToken ExampleSpinner Usage
ColorcurrentColor / --color-primary-500Spinner stroke or fill color
Color – Track--color-neutral-200Inactive 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-12Spinner diameter per size variant
Border Width--border-width-2 / --border-width-3Ring stroke thickness
Animation Duration--duration-slow (1.2s)Rotation cycle duration
Animation Timing--ease-linearRotation uses linear timing; acceleration on the arc uses ease-in-out. Preview with Animation & Easing Tool.
Z-Index--z-overlayZ-index for overlay spinners
Spacing--space-2 / --space-3Gap between spinner and label text

States

Spinner States

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:

StateBehaviorImplementation
HiddenSpinner 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.
DelayedTimer running but spinner not yet visible.Use setTimeout or CSS animation-delay to prevent flash-of-spinner for fast operations.
SpinningActively animating. Accessible label being announced.Render with role="status" and aria-label. Set aria-busy="true" on the content region being loaded.
With LabelSpinning alongside a text message ("Loading your dashboard…").Visually and semantically pair the label with the spinner.
OverlaySpinner 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".
CompletedSpinner 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.

The Spinner Lifecycle

For the best user experience, follow this lifecycle:

  1. Operation starts → Start delay timer (300ms)
  2. Delay expires, still loading → Fade spinner in (150ms)
  3. Operation completes → Fade spinner out (150ms) or morph to checkmark
  4. Operation fails → Replace spinner with error state or Alert

This prevents flash-of-spinner and ensures smooth transitions. Preview fade animations with the Animation & Easing Tool.

Accessibility

Accessibility — Spinner

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.

ARIA Pattern

<!-- 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:

  • Use role="status" (implicit aria-live="polite") so the loading message is announced without interrupting the user
  • Mark the SVG/animation as aria-hidden="true" — screen readers should read the text, not try to describe the animation
  • Set aria-busy="true" on the container whose content is loading — this tells AT that the region is updating

WCAG Success Criteria

CriterionLevelRequirement for Spinners
1.1.1 Non-text Content (A)AThe visual spinner must have a text alternative. Use aria-label or visible label text.
1.3.1 Info and Relationships (A)AUse role="status" to convey that this element communicates a loading state.
1.4.1 Use of Color (A)ADon't rely on the spinner's color alone to communicate meaning. Pair with text labels.
1.4.11 Non-text Contrast (AA)AAThe 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)AIf 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)ASpinner 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)AALoading state changes must be programmatically determinable. role="status" satisfies this — the content is announced as a live region update.

Motion Sensitivity

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.

Usage Guidelines

Do

  • Always include a delay (300–500ms) before showing the spinner. If the operation is faster, the user never sees it — which is the ideal experience.
  • Provide a text label whenever possible. "Loading" is better than nothing, but "Loading search results…" is better than "Loading."
  • Use the smallest appropriate size. A button spinner should be xs/sm, not a massive lg spinner crammed into a 40px button.
  • Set aria-busy="true" on the content region being loaded, not on the spinner itself.
  • Respect prefers-reduced-motion. Replace continuous rotation with a gentle pulse or static indicator.
  • Remove spinners completely when loading finishes — don't just hide them visually. Remove from DOM and accessibility tree.
  • Use currentColor for the spinner color so it automatically adapts to its text context (dark text = dark spinner, light text = light spinner).

Don't

  • Don't use a spinner when you have progress data. Use a Progress Bar.
  • Don't use a spinner when you know the content shape. Use a Skeleton.
  • Don't stack multiple spinners in the same view. One loading state per visible region. If the whole page is loading, use one overlay spinner or a skeleton screen.
  • Don't use spinners for operations over 10 seconds without additional context. After 10 seconds, users wonder if something is broken. Add "This may take a few moments…" or switch to a progress bar.
  • Don't block the entire page unless absolutely necessary. Prefer inline or section spinners that allow the user to interact with other parts of the UI.
  • Don't animate at framerates that cause flashing. WCAG 2.3.1 prohibits content that flashes more than 3 times per second.
  • Don't use a spinner as a permanent state. If loading fails, transition to an error state or Empty State.

Performance Considerations

Spinners should be lightweight:

  • Prefer CSS animations over JavaScript-driven ones (CSS animations run on the compositor thread)
  • Use transform: rotate() not border-color animations for GPU acceleration
  • SVG spinners are resolution-independent and typically smaller than GIF/Lottie alternatives
  • Explore optimized spinner patterns in the Loader Generator

Code Snippets

html
<!-- 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>
tsx
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";

Design Systems

Design System Implementations

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.

FeedbackLoading SpinnerActivity Indicator