Loading Spinner
Examples
Default
Tones
Figma Type: Primary, Negative, Neutral (theme inverse surfaces), Static Black, Static White.
neutral flips with color mode (light bar on dark surfaces in light mode; dark bar on light surfaces in dark mode). Static tones stay fixed — demo them on matching panels so they stay readable in both modes.
Sizes
Sm 20px · Md 24px (default) · Lg 28px · Xl 44px — outer box including Figma’s 2px padding.
RTL
Rotation is direction-agnostic; labels follow the document language.
API Reference
LoadingSpinner
| Prop | Type | Default | Description |
|---|---|---|---|
tone | "primary" | "negative" | "neutral" | "static-black" | "static-white" | "primary" | Figma Type — bar and track colors |
size | "sm" | "md" | "lg" | "xl" | "md" | Outer box 20px / 24px / 28px / 44px |
label | string | "Loading" | Accessible name (aria-label when not decorative) |
className | string | — | Extra classes |
aria-hidden | boolean | — | Decorative mode (no role="status") — used inside busy buttons |
Track and bar colors map to --eds-color-border-*, --eds-color-adaptive-*, and --eds-color-static-* tokens.
Accessibility
WCAG success criteria
| Criterion | Level | How EDS helps | |
|---|---|---|---|
| 4.1.3 Status Messages | AA | Standalone spinners expose role="status" with an aria-label so assistive tech can surface progress without moving focus | |
| 1.4.3 Contrast (Minimum) | AA | Token-mapped bar/track colors; choose tone appropriate to the background | |
| 2.3.3 Animation from Interactions | AAA | Respects prefers-reduced-motion by slowing rotation (does not disable — spinner remains recognizable) | |
| 2.2.2 Pause, Stop, Hide | A | Continuous animation is decorative/informational; paired with status text; motion reduced under user OS preference | |
| 1.1.1 Non-text Content | A | SVG is aria-hidden; meaning is carried by aria-label / parent context, or omitted when decorative |
Why it matters
A loading indicator tells users that work is in progress and the UI has not frozen. Without a proper status announcement, screen-reader users may think nothing happened after an action — especially on slow networks or after form submission. Spinners that spin forever without context create anxiety for everyone; a clear, localized label (“Saving changes…”) reduces cognitive load and builds trust.
When embedded in a button, the spinner must stay decorative so assistive tech hears one coherent “Saving, busy” message from the button — not a duplicate “Loading” from the spinner itself.
Keyboard
LoadingSpinner is not focusable and does not participate in tab order. Keyboard interaction applies to the host control (page region, dialog, button) that owns the loading state.
| Key / action | Behavior |
|---|---|
| ⇥ Tab | Skips the spinner — focus moves to the next focusable element in document order |
| ⇧ + ⇥ Tab | Same — spinner is not in the tab sequence |
| ↵ Enter / Space | No effect on the spinner itself; activates the focused host control if one is present |
| ⎋ Esc | No effect on the spinner; may dismiss a parent dialog or cancel a parent action |
| Host context | What keyboard users experience |
|---|---|
| Standalone page loader | Focus remains on last focused element or moves per app logic; spinner is visual-only unless label announces status |
Button with loading | Button stays focusable; aria-busy="true" on the button conveys busy state; spinner is aria-hidden |
| Modal / drawer overlay | Focus trap on the dialog; spinner inside does not steal focus |
Screen readers & semantics
| Mode | DOM semantics | What AT announces |
|---|---|---|
| Standalone (default) | role="status" + aria-label from label prop (default "Loading") | Status region — label is read when inserted or when content changes (behavior varies by AT) |
| Decorative | aria-hidden="true" — no role="status", label ignored | Nothing — parent must convey loading (aria-busy, visible text, live region) |
| SVG graphic | Inner <svg aria-hidden="true" focusable="false"> | Never announced separately from the status label |
| Prop / attribute | Role |
|---|---|
label | Sets accessible name via aria-label when not decorative; localize for the action (“Uploading file…”) |
aria-label (native attr) | Overrides label when provided |
aria-hidden | Switches to decorative mode — required when nested in Button loading or other labeled hosts |
The spinner does not use aria-live directly; role="status" maps to an implicit live region in most browsers. Avoid nesting multiple standalone spinners with identical labels on one screen.
Focus & visuals
| Concern | EDS behavior |
|---|---|
| Focus | Spinner is not focusable (tabIndex not set); no focus ring on the spinner itself |
| Contrast | Bar and track use design tokens (--eds-color-border-*, --eds-color-adaptive-*, --eds-color-static-*); pick tone to meet 3:1 non-text contrast against the surface |
| Motion | Continuous rotation at 0.75s per revolution; under prefers-reduced-motion: reduce, animation slows to 1.5s (still indeterminate, less aggressive) |
| RTL | Rotation is direction-agnostic; label text follows document lang / reading direction |
neutral tracks theme inverse surfaces. static-black / static-white stay fixed — use them on branded or locked light/dark panels where theme colors must not shift.
Build with it
| Do | Don’t | ||
|---|---|---|---|
Use a specific label for the action: "Saving profile", "Deleting item" | Leave the default "Loading" when context is unclear | ||
Set aria-hidden when the parent already announces busy state (Button loading, aria-busy region) | Render a standalone spinner and duplicate the same message on the parent | ||
Match tone to the surface (neutral on inverse; static-white / static-black on fixed dark/light panels) | Put static-black on a dark surface or static-white on a light one | ||
| Remove or hide the spinner when loading completes | Leave a spinner visible indefinitely with no status update | ||
Localize label for RTL locales (see Usage → RTL) | Hard-code English labels in localized apps |
Button loading pattern — Button sets aria-busy="true" and renders <LoadingSpinner aria-hidden /> so users hear one busy button, not spinner + button:
<Button loading>Saving…</Button>Standalone page loader:
<LoadingSpinner label="Loading dashboard" size="lg" />Quick test
- Standalone announcement — Render
<LoadingSpinner label="Loading data" />. With VoiceOver (macOS) or NVDA (Windows), verify the status label is available in the rotor / element list (role="status"). - Decorative mode — Render
<LoadingSpinner aria-hidden />inside a<div aria-busy="true">Loading…</div>. Confirm the spinner itself is not announced separately. - Button loading — Toggle
Button loading. Confirm you hear “busy” (or equivalent) once from the button, not a second “Loading” from the spinner. - Reduced motion — Enable Reduce motion in OS settings. Reload — rotation should be noticeably slower (1.5s vs 0.75s).
- Contrast spot-check — Place each
toneon its intended surface; confirm the moving bar is visible at arm’s length and meets team contrast targets. - RTL — Set
dir="rtl" lang="fa"on a parent with a Persianlabel; confirm label text reads correctly and layout is unaffected.