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