Design System

Loading & progress states

Temporal conditions that deserve a signal.
SPINNER · 200ms-1s · inline with the action Saving… 1s linear rotation · electric-500 stroke PROGRESS BAR · 5s-30s · real progress only SYNCING TRACKS · 65% A bar that lies is worse than a spinner that never claimed to know. SKELETON · 1s-5s · final shape in placeholder navy-700 placeholders match final-content shape FULL-SURFACE · >30s · cancellable This is taking longer than usual. Cancel and try again? INLINE NOTHING · <200ms · no affordance at all A flash of loading UI that resolves before it registers reads as a glitch. Render the final state directly.

Selection by duration

The choice of loading affordance is a function of expected duration, not visual preference. Different waits need different signals.

Expected duration Affordance
< 200ms Nothing. Do not show a spinner. A flash of loading UI that completes before the user perceives it reads as a glitch - worse than no feedback.
200ms – 1s Inline spinner replacing the action control. wa-spinner at 16px, electric-500. The action button disables and the spinner occupies its label slot.
1s – 5s Skeleton screen for the content being fetched. The shape of the result renders in navy-700 placeholder rectangles matching the final layout.
5s – 30s Determinate progress bar if progress is measurable (wa-progress-bar). Otherwise skeleton screen with a secondary “Still working…” line appearing after 3 seconds.
> 30s Full-surface loading state with a cancellable action: “This is taking longer than usual. Cancel and try again?” The operator always has an out.
> 60s Automatic transition to 408 timeout state. See System states.

Spinner (wa-spinner)

  • Color: electric-500
  • Size: 16px (inline with text), 24px (standalone), 32px (full-surface)
  • Animation: 360° rotation, 1s linear, infinite
  • Accompanying label: optional. If present, Inter 400 fog-200 to the right of the spinner with 8px gap.

Spinners carry no progress information by definition. They only say “I’m working.” If the wait is long enough that the operator wonders “how long,” you need a progress bar or a skeleton screen instead - escalate.

Progress bar (wa-progress-bar)

  • Fill: electric-500 with --fdt-glow-xs on the leading edge
  • Track: navy-700, 4px tall, fully rounded ends (2px radius)
  • Label above the bar: JetBrains Mono 10px, steel-300 - {operation} · {percentage}% - e.g., SYNCING TRACKS · 34%
  • Indeterminate variant: same bar with a 30% electric-500 sliver animating across the track, 2s ease-in-out, looping

Only use a progress bar when you can report real progress. A bar that lies - fills to 90% and hangs - is worse than a spinner that never claimed to know. Progress is a promise; keep it.

Skeleton screens

The preferred pattern for content loads (1–5 seconds). Skeletons render the final layout’s shape in neutral placeholders, which reduces the operator’s sense of waiting and lets them start parsing the structure before the data arrives.

Anatomy

  • Placeholder color: navy-700, no texture, no border
  • Shimmer effect (optional): a subtle electric-500 gradient sweeps across the skeleton at 20% opacity, 1.5s linear loop. Disabled under prefers-reduced-motion: reduce. Skip shimmer entirely for skeletons that stay visible less than 1 second - the sweep doesn’t complete a cycle.
  • Shapes:
    • Text line - navy-700 rectangle at 60–80% of final line width, matching the line-height of the real content
    • Avatar / icon placeholder - navy-700 circle or square at the icon’s size
    • Card / panel placeholder - navy-700 rectangle matching the final card outline, 1px navy-600 border
    • Chart placeholder - the plot area renders with gridlines at 15% opacity and no data series

Skeletons must match the shape of the real content. A skeleton for a three-line description is three rectangles, not one block. A skeleton for a 12-column dashboard shows 12 columns, not a centered spinner. The closer the skeleton is to the final shape, the smoother the reveal.

Progressive disclosure

Render what you have as you get it instead of holding the entire surface in skeleton until the last byte arrives.

  • A dashboard with 6 charts renders each chart independently - chart 1 appears when its query completes, then chart 2, not all six after the slowest query.
  • A list with pagination renders the first page before requesting page 2.
  • A map loads the tile pyramid progressively (low zoom first, then refine as higher-resolution tiles arrive), not all at once.
  • A search result populates the first few hits immediately, then backfills the tail.

The operator starts reading the moment anything is readable. Holding back content to present a “clean” reveal is a consumer-app pattern; in a tactical context it’s pure latency. Every second of unnecessary skeleton is a second the operator can’t spend thinking.

Live regions (ARIA)

Loading states announce themselves to screen readers via ARIA live regions - the audio channel needs the same information the visual shimmer provides:

  • Mount: aria-busy="true" on the loading container, plus an aria-live="polite" status region containing “Loading {resource}…”
  • Progress: aria-valuenow, aria-valuemin, aria-valuemax on the progress bar
  • Completion: the status region briefly updates to “Loaded” before the final content takes focus
  • Timeout: the status region updates to “Request timed out” and the surface transitions to the 408 error state

Operators using screen readers don’t have the visual shimmer; the live region is how they know something is happening. Never skip this. A silent loading state fails accessibility.

Timeout behavior

Every loading state has a defined timeout. A loading state that never resolves is worse than an error - the operator has no idea whether to wait, retry, or report.

Loading pattern Timeout On timeout
Inline spinner (action button) 10s Transition to retry state on the same button
Skeleton screen 30s Transition to 408 timeout state (System states)
Determinate progress bar 30s since last progress tick, not 30s total Transition to error if progress has stalled
Full-surface loading 60s max, always cancellable Transition to 408, preserve operator input where possible

Every loading state has a “worst case: error” fallback defined. No loading screen ships without its timeout specified.

Composition with system states

Loading and error states share anatomy:

  • Both live in the same surface container
  • Both carry classification strips (where applicable)
  • Both use the same icon size (48px standalone, 32px inline)
  • Both have a primary action on resolution

The transitions between them are explicit - a skeleton replaces into an error state when its timeout fires; an error state replaces into a loading state when the user hits retry. No morph, no crossfade - the replacement is instant and the status region announces the change.

Anti-patterns

  • Spinner for operations under 200ms. Causes flashing UI on fast connections. Render nothing; let the state change happen.
  • Full-surface spinner when a skeleton would work. Skeleton is strictly better above 1 second - it tells the operator what’s coming, the spinner tells them only that something’s happening.
  • Progress bars that don’t reflect progress. An indeterminate progress bar is honest about not knowing. A fake progress bar that inches forward on a timer is a lie, and operators notice.
  • Loading states without timeouts. Every loading eventually becomes a success or an error. No exceptions.
  • Blocking the whole UI for a partial load. The operator should be able to navigate away, cancel, or interact with other already-loaded content while a subtree loads. Global spinners are a code smell.
  • “Success” states as separate screens. The successful load IS the success state - the data arrives and renders. A separate “✓ Loaded!” confirmation is consumer-app theater; skip it.
  • Skeleton screens for instant operations. If you know the operation completes in under 200ms, render nothing. The skeleton-flash is its own form of noise.