Loading States
Match the loading indicator to the wait — when to show nothing, a spinner, a progress bar, or a skeleton, and how to keep any of them from flashing.
Dependencies
Overview
Foundations ships every piece of a loading state — Spinner (indeterminate), Progress (determinate), Skeleton (content-shaped placeholder), and useDelayedLoading (anti-flash timing). The hard part isn’t any one of them; it’s knowing which to reach for, and when. This guide is the decision.
The wait-time ladder
How long the user will wait determines what you show. The thresholds come from Primer’s loading guidance:
| Expected wait | Indicator |
|---|---|
| < 1s | Nothing — an indicator would only flash |
| 1–3s | Spinner — indeterminate, “something’s happening” |
| 3–10s | Progress — determinate, “this much is done” |
| 10s+ | Progress, and let the user keep working |
The instinct to “always show a spinner so the user knows something happened” backfires below a second: a spinner that appears and vanishes within a couple of frames reads as a glitch and makes the product feel slower, not faster.
Two decisions
Showing the right loading state is two separate calls, and you make both:
- Which indicator fits the expected wait — the ladder above.
- When it’s allowed on screen — wrap its visibility in
useDelayedLoadingso it’s deferred past a short delay and, once shown, held for a minimum. That’s what guarantees a spinner or a progress bar never flashes, no matter how fast the work resolves on a good connection.
Choosing an indicator by duration
Each button simulates work of a different length. The demo picks the indicator the ladder prescribes and gates its visibility with useDelayedLoading. Note that the quick task shows nothing at all.
- Quick: Under 1s → show nothing. An indicator would only flash.
- Medium: 1–3s → an indeterminate spinner.
- Long: 3–10s → a determinate progress bar.
import { useRef, useState } from 'react';
import { useDelayedLoading } from '@/foundations/hooks/use-delayed-loading';
import { useTicker } from '@/foundations/hooks/use-ticker';
import { Button } from '@/components/button';
import { Progress } from '@/components/progress';
import { Spinner } from '@/components/spinner';
import type { PreviewMeta } from '@/lib/preview';
type Indicator = 'none' | 'spinner' | 'progress';
// The wait-time ladder: pick an indicator from the expected duration.
const indicatorFor = (expectedMs: number): Indicator =>
expectedMs < 1000 ? 'none' : expectedMs < 3000 ? 'spinner' : 'progress';
const cases = [
{
label: 'Quick',
duration: 200,
caption: 'Under 1s → show nothing. An indicator would only flash.',
},
{
label: 'Medium',
duration: 2000,
caption: '1–3s → an indeterminate spinner.',
},
{
label: 'Long',
duration: 6000,
caption: '3–10s → a determinate progress bar.',
},
];
function LoadingStatesLadderPreview() {
const [isLoading, setIsLoading] = useState(false);
const [indicator, setIndicator] = useState<Indicator>('none');
const [value, setValue] = useState(0);
const showLoading = useDelayedLoading(isLoading);
const timeout = useRef<number | null>(null);
const startedAt = useRef(0);
const duration = useRef(0);
const ticker = useTicker(() => {
const pct = Math.min(
100,
((performance.now() - startedAt.current) / duration.current) * 100
);
setValue(pct);
if (pct >= 100) return false;
});
const load = (ms: number) => {
if (timeout.current) window.clearTimeout(timeout.current);
const next = indicatorFor(ms);
setIndicator(next);
setValue(0);
setIsLoading(true);
if (next === 'progress') {
startedAt.current = performance.now();
duration.current = ms;
ticker.start();
}
timeout.current = window.setTimeout(() => {
setIsLoading(false);
ticker.stop();
}, ms);
};
return (
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap justify-center gap-2">
{cases.map((c) => (
<Button
key={c.label}
variant="outline"
disabled={isLoading}
onClick={() => load(c.duration)}
>
{c.label} (
{c.duration < 1000 ? `${c.duration}ms` : `${c.duration / 1000}s`})
</Button>
))}
</div>
<div className="flex h-8 w-64 items-center justify-center text-foreground-secondary text-sm">
{showLoading && indicator === 'spinner' && (
<span className="flex items-center gap-2">
<Spinner /> Loading…
</span>
)}
{showLoading && indicator === 'progress' && (
<Progress value={value} className="w-full" />
)}
{!(showLoading && indicator !== 'none') && (
<span className="opacity-60">{isLoading ? 'Working…' : 'Idle'}</span>
)}
</div>
<ul className="max-w-sm space-y-1 text-center text-foreground-secondary text-xs">
{cases.map((c) => (
<li key={c.label}>
<strong className="text-foreground">{c.label}</strong>: {c.caption}
</li>
))}
</ul>
</div>
);
}
export const meta = {
layout: 'centered',
} satisfies PreviewMeta;
export default LoadingStatesLadderPreview; Loading known content
Duration isn’t the only axis. When you already know the shape of what’s loading — a card, a row, a profile — render a Skeleton in that shape instead of a spinner. It preserves layout so nothing jumps when the real content arrives, and it communicates what is coming, not just that something is. Gate it with useDelayedLoading too, so a fast response doesn’t flash a skeleton.
The skeleton mirrors the card's layout, so nothing shifts when content arrives. A quick reload finishes before the delay, so the skeleton never flashes.
import { useRef, useState } from 'react';
import { useDelayedLoading } from '@/foundations/hooks/use-delayed-loading';
import { Avatar } from '@/components/avatar';
import { Button } from '@/components/button';
import { Skeleton } from '@/components/skeleton';
import type { PreviewMeta } from '@/lib/preview';
function LoadingStatesSkeletonPreview() {
const [isLoading, setIsLoading] = useState(false);
const timeout = useRef<number | null>(null);
const showSkeleton = useDelayedLoading(isLoading);
const reload = (ms: number) => {
if (timeout.current) window.clearTimeout(timeout.current);
setIsLoading(true);
timeout.current = window.setTimeout(() => setIsLoading(false), ms);
};
return (
<div className="flex flex-col items-center gap-6">
<div className="flex gap-2">
<Button
variant="outline"
disabled={isLoading}
onClick={() => reload(1500)}
>
Reload (1.5s)
</Button>
<Button
variant="outline"
disabled={isLoading}
onClick={() => reload(150)}
>
Quick reload (150ms)
</Button>
</div>
<div className="w-64 rounded-xl border border-border p-4">
{showSkeleton ? (
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
) : (
<div className="flex items-center gap-3">
<Avatar size="lg">
<Avatar.Fallback>Ada Lovelace</Avatar.Fallback>
</Avatar>
<div className="flex flex-1 flex-col">
<span className="font-medium text-sm">Ada Lovelace</span>
<span className="text-foreground-secondary text-xs">
Analytical Engine
</span>
</div>
</div>
)}
</div>
<p className="max-w-sm text-center text-foreground-secondary text-xs">
The skeleton mirrors the card's layout, so nothing shifts when content
arrives. A quick reload finishes before the delay, so the skeleton never
flashes.
</p>
</div>
);
}
export const meta = {
layout: 'centered',
} satisfies PreviewMeta;
export default LoadingStatesSkeletonPreview; 10 seconds and beyond
Past ten seconds, a blocking indicator is hostile. Keep a determinate Progress so the wait stays legible, but stop blocking the UI: let the user move elsewhere while the work runs, and surface completion out-of-band (a toast, a badge, an updated row) rather than holding them on a frozen screen.
A note on motion
Spinner, Progress, and Skeleton animate regardless of prefers-reduced-motion — a loading indicator communicates state, so freezing it would break the signal. See the Spinner page for the rationale.
Previous
Hierarchical Selection
Next
Performance tracking & bundle analyzer