Skip to content

Loading States

Six strategies, chosen by duration and by how much of the screen is changing. The best loading state is the one the user never sees.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

service-1

main · 4m ago

service-2

main · 4m ago

service-3

main · 4m ago

300ms – 3s, known layout — Reserves the exact space, so nothing shifts when the data lands.

Choosing a strategy

Duration down, scope across. Almost every loading decision in a product is one of these six cells.

DurationSmall regionLarge region
< 100msNothingNothing
100ms – 1sInline spinner (after 200ms)Skeleton
1s – 10sInline spinner + disableSkeleton, or determinate progress
> 10sBackground job + notificationProgress surface, let the user leave

Stale while revalidating

The strongest pattern for a returning user. Show what you already have, refresh quietly, and mark the data as being updated rather than removing it.

Stale content stays readable

1.24M

Updated 4 minutes ago · refreshing

Content replaced by a spinner

In-place acknowledgement

The clearest feedback appears in the control the user just pressed. The button keeps its width, shows a spinner, and blocks a second submission.

idle → loading → success → idle

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Idle
PendingWidth held
Success
Inline spinner
Skeleton
Determinate
Indeterminate
service-3
OptimisticDimmed until confirmed
3 results so far…
Streaming
Updated 4m ago
Stale
Refreshing
Refreshing
Up to date
Complete

Anatomy

Every part, every measurement, and the reason it is that number.

Requests

1.24M

Updated 4 minutes ago · refreshing

A background refresh. The value stays readable, the spinner is small and peripheral, and the staleness is stated in words.

  1. Delay before showing200ms

    Most requests finish faster. Without the delay, every fast response produces a flash that reads as instability.

  2. Indicator size13px, peripheral

    Proportional to the region being updated. A 40px spinner over a single stat is louder than the change it describes.

  3. Content opacity100%, not dimmed

    Stale data is still useful data. Dimming it to 50% makes it unreadable while offering nothing in return.

  4. Staleness label"Updated 4 minutes ago"

    The honest version of a loading state: the user knows exactly how much to trust what they are reading.

  5. Layout stabilityNo dimension change

    Nothing resizes between loading and loaded. Every pixel of shift is a chance the user clicks the wrong thing.

  6. Minimum visible time~400ms once shown

    If the indicator has appeared, hold it briefly. An indicator that vanishes in 50ms is a flicker.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-layer-active—Skeleton fill
--ds-fg-muted—Spinner and staleness label
--ds-accent—Progress fill
--ds-warning-text—Stale-data notice

Spacing

TokenValueUsed for
indicator sizeInline, control, region

Motion

TokenValueUsed for
delayBefore any indicator appears
minimumOnce shown, before it may disappear
spinSpinner rotation
shimmerSkeleton sweep

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightIconWhen to use
Inline—12–14pxInside a control, a table cell, or beside a label.
Control—16pxInside a button, replacing the label without changing the width.
Region—20–24pxCentred in a card or a panel that has no known layout.
PageSkeleton—Never a centred spinner on a full page — use a skeleton of the real layout.
Bar2px—Pinned to the top of a table or the page during a background refresh.

Do

const t = setTimeout(() => setBusy(true), 200)
return () => clearTimeout(t)
Delay the indicator by 200msMost requests resolve faster than that. The delay costs nothing on slow responses and removes the flash on fast ones, which is what makes an app feel stable.

1.24M

Updated 4 minutes ago

Keep stale content on screenFour-minute-old numbers are far more useful than a spinner. Refresh underneath and say when the data was last updated.
Acknowledge in the control that was pressedThe user is looking at the button. Feedback anywhere else costs a saccade, and feedback that changes the button’s width costs a mis-click.
api-gatewayLive
billing-workerLive
Be optimistic when success is likely and cheap to undoAdding a tag, toggling a favourite, sending a message — show it immediately and reconcile after. The interface stops having a latency at all.

Don't

Do not put a full-page spinner over a small changeA modal loader for a 300ms filter change makes a fast application feel slow, and it removes the content the user was reading.
Do not replace content with a spinner on refreshThe user loses their place and their data for the duration of a request that will almost certainly return the same thing.
Do not stack multiple indicatorsA page spinner, three card skeletons and a top progress bar for one request is three answers to one question, and it looks broken.
show at 0ms → hide at 40ms → the user sees a flash
Do not let an indicator flickerA spinner that appears for 40ms is a visual glitch. Once shown, hold it for at least 400ms even if the response has already arrived.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

4.1.3Status MessagesAA2.2.1Timing AdjustableA1.4.13Content on Hover or FocusAA2.4.3Focus OrderA

Contrast

  • A spinner is a meaningful graphic and must reach 3:1 against its background.
  • Do not reduce content opacity below about 55% while loading — text at 40% opacity fails contrast and becomes genuinely unreadable.

Keyboard

TabMust still work during loading. Disabling the whole page traps the user.
EnterA pending button must not re-submit. Disable it or guard the handler.
EscShould cancel a cancellable request, where one exists.

Screen readers

  • Announce the state change once at each end, not continuously. A live region that fires on every progress tick is unusable.
  • A spinner with no text is silent. Pair it with a visually hidden "Loading" or an aria-label naming what is loading.
  • Optimistic updates should be announced as provisional if they can fail: "Sending", then "Sent" or "Could not send".

Focus & touch

  • Never move focus because something started or finished loading. If focus was in a filter field, it must still be there when results arrive — otherwise the user is thrown back to the top of the page mid-task.
  • Do not block the whole screen with an overlay for a short wait. On mobile that removes scrolling entirely, and users interpret an unresponsive page as a crash.
AttributeApplied toNotes
aria-busy="true"The loading regionOne flag on the container. Not on every skeleton block inside it.
aria-live="polite"A status regionAnnounce the transition once: "Loading projects", then "12 projects loaded".
aria-disabledA pending buttonPrefer over disabled when the button must stay focusable and explain itself.
role="status"An inline spinnerWith a visually hidden label. A bare spinning icon announces as nothing.
aria-hidden="true"Skeleton blocksThey are decorative placeholders, not content.

Code

Example usage

tsx
1// 1. Delay in, minimum out — the two rules that remove flicker2function useLoadingIndicator(active: boolean, delay = 200, min = 400) {3  const [show, setShow] = useState(false)4  const shownAt = useRef(0)56  useEffect(() => {7    if (active) {8      const t = setTimeout(() => {9        shownAt.current = Date.now()10        setShow(true)11      }, delay)12      return () => clearTimeout(t)13    }14    if (!show) return15    const held = Date.now() - shownAt.current16    const t = setTimeout(() => setShow(false), Math.max(0, min - held))17    return () => clearTimeout(t)18  }, [active, delay, min, show])1920  return show21}2223// 2. Stale while revalidating — keep the content, mark it stale24const { data, isFetching, dataUpdatedAt } = useQuery({25  queryKey: ['metrics'],26  queryFn: fetchMetrics,27  staleTime: 30_000,28  placeholderData: (prev) => prev,      // never blank on refetch29})3031<Stat value={data.requests} />32<p className="text-caption text-fg-muted">33  Updated {formatRelative(dataUpdatedAt)}{isFetching && ' · refreshing'}34</p>3536// 3. Optimistic — show it now, reconcile after37async function addTag(tag: string) {38  const optimistic = { id: 'tmp-' + tag, name: tag, pending: true }39  setTags((t) => [...t, optimistic])40  try {41    const saved = await api.addTag(tag)42    setTags((t) => t.map((x) => (x.id === optimistic.id ? saved : x)))43  } catch {44    setTags((t) => t.filter((x) => x.id !== optimistic.id))45    toast({ tone: 'danger', title: 'Could not add ' + tag })46  }47}4849// 4. Prefetch on intent — the wait disappears entirely50<Link onMouseEnter={() => prefetch(href)} onFocus={() => prefetch(href)} />

CSS

css
/* Stale content stays readable. 55% is the floor. */
.is-refetching { opacity: 0.55; transition: opacity 160ms var(--ease-standard); }

/* A 2px bar at the top of the region being refreshed. Least
   intrusive indicator there is — it changes nothing below it. */
.region { position: relative; }
.region__loading-bar {
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
  block-size: 2px;
  overflow: hidden;
}

/* A pending button keeps its width — the label stays in the DOM */
.ds-btn[aria-busy='true'] .ds-btn__label { visibility: hidden; }
.ds-btn[aria-busy='true'] .ds-btn__spinner {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

/* Do not block the page. If you must overlay, keep it scoped
   to the region and keep the scrim light. */
.region--blocking::after {
  content: '';
  position: absolute;
  inset: 0;
  background: color-mix(in oklab, var(--ds-canvas) 60%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .ds-spinner { animation-duration: 2s; }
}

Notes

Professional tips

  • Prefetch on hover and on focus. For a link the user is about to click, the data is often already there by the time they click — no loading state needed at all.
  • Measure p95, not the average. The loading state you think is rare is usually showing on a meaningful share of requests.
  • For a slow endpoint you cannot fix, render the page shell and the navigation immediately and load only the data region. Perceived speed is mostly about the first paint.
  • When a request exceeds about eight seconds, change the message rather than the indicator: "Still working — this is taking longer than usual" is far better than the same spinner.

Performance

  • React Suspense with streaming SSR sends the shell first and the data as it resolves. It converts one long wait into several short ones.
  • Do not render the loading tree and the content tree at once. Conditional rendering keeps the DOM small; hiding one with CSS pays for both.
  • Debounce search-driven loading by 300ms and cancel in-flight requests with AbortController, or a slow early response can overwrite a fast later one.
  • Cache aggressively with a short stale time. Most navigations in an application are back to somewhere the user has already been.

Common mistakes

  • Showing the indicator immediately, so every fast response flashes.
  • Hiding it the instant the response lands, so the indicator flickers.
  • Replacing content with a spinner on background refresh, losing the user’s place for no benefit.
  • A full-page overlay for a small change, which makes a fast app feel slow.
  • Moving focus when loading completes, throwing the user out of whatever they were typing.

Real-world recommendations

  • Optimistic updates are the single biggest perceived-performance win available, and they are mostly a state-management decision rather than a design one. Budget for them early.
  • Instrument how often each loading state is actually seen. States that appear on 40% of interactions deserve real design attention; ones that appear on 0.1% do not.
  • Users tolerate a slow operation far better than an unpredictable one. Consistent 800ms beats a range of 200ms to 3s, even though the average is worse.
  • For anything over ten seconds, send a notification on completion and let the user leave. Holding the tab open is not a safety measure, it is a design failure.