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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
- Delay before showing200ms
Most requests finish faster. Without the delay, every fast response produces a flash that reads as instability.
- Indicator size13px, peripheral
Proportional to the region being updated. A 40px spinner over a single stat is louder than the change it describes.
- Content opacity100%, not dimmed
Stale data is still useful data. Dimming it to 50% makes it unreadable while offering nothing in return.
- 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.
- Layout stabilityNo dimension change
Nothing resizes between loading and loaded. Every pixel of shift is a chance the user clicks the wrong thing.
- Minimum visible time~400ms once shown
If the indicator has appeared, hold it briefly. An indicator that vanishes in 50ms is a flicker.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-layer-active | — | Skeleton fill |
| --ds-fg-muted | — | Spinner and staleness label |
| --ds-accent | — | Progress fill |
| --ds-warning-text | — | Stale-data notice |
Spacing
| Token | Value | Used for |
|---|---|---|
| indicator size | Inline, control, region |
Motion
| Token | Value | Used for |
|---|---|---|
| delay | Before any indicator appears | |
| minimum | Once shown, before it may disappear | |
| spin | Spinner rotation | |
| shimmer | Skeleton sweep |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Icon | When to use |
|---|---|---|---|
| Inline | — | 12–14px | Inside a control, a table cell, or beside a label. |
| Control | — | 16px | Inside a button, replacing the label without changing the width. |
| Region | — | 20–24px | Centred in a card or a panel that has no known layout. |
| Page | Skeleton | — | Never a centred spinner on a full page — use a skeleton of the real layout. |
| Bar | 2px | — | Pinned to the top of a table or the page during a background refresh. |
const t = setTimeout(() => setBusy(true), 200)
return () => clearTimeout(t)1.24M
Updated 4 minutes ago
Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Must still work during loading. Disabling the whole page traps the user. |
| Enter | A pending button must not re-submit. Disable it or guard the handler. |
| Esc | Should 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.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-busy="true" | The loading region | One flag on the container. Not on every skeleton block inside it. |
| aria-live="polite" | A status region | Announce the transition once: "Loading projects", then "12 projects loaded". |
| aria-disabled | A pending button | Prefer over disabled when the button must stay focusable and explain itself. |
| role="status" | An inline spinner | With a visually hidden label. A bare spinning icon announces as nothing. |
| aria-hidden="true" | Skeleton blocks | They are decorative placeholders, not content. |
Example usage
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
/* 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; }
}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.