Agents (llms.txt)
Octocat

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 waitIndicator
< 1sNothing — an indicator would only flash
1–3sSpinner — indeterminate, “something’s happening”
3–10sProgress — 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:

  1. Which indicator fits the expected wait — the ladder above.
  2. When it’s allowed on screen — wrap its visibility in useDelayedLoading so 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.

Idle
  • 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.

AL
Ada LovelaceAnalytical Engine

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