# Give Loading States a Show-Delay and a Floor

**MUST** · **ID:** `interactions-loading-state-duration` · **Category:** interactions
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-state-duration

> MUST: Gate every spinner/skeleton with two timers: a show-delay of ~150–300ms (fast responses show nothing) and a minimum visible time of ~300–500ms once it appears, so it never flashes. React `<Suspense>` already does this.

Delay the spinner by ~150–300ms and keep it up for ~300–500ms once it appears

Other loading rules cover what to render; this one covers when. Mount the spinner on the same tick the request starts and a 60ms response paints it for 60ms — below the ~100ms threshold at which people perceive a state at all, so it reads as a flash of damage rather than progress. Repeat that on every keystroke or every click and the panel strobes. Two timers fix it: a show-delay of roughly 150–300ms, so anything that finishes fast shows no spinner whatsoever, and a minimum visible time of roughly 300–500ms once it does appear, so a response landing 20ms later does not yank it away. React's <Suspense> applies the same shape internally.

## Bad — do not do this

`interactions-loading-state-duration-bad`

```tsx
import { useRef, useState } from 'react';

type Speed = 'fast' | 'slow';

const LATENCY: Record<Speed, number> = { fast: 60, slow: 1200 };

export function LoadingStateDurationBad() {
  const [speed, setSpeed] = useState<Speed>('fast');
  const [pending, setPending] = useState(false);
  const [log, setLog] = useState<{ shown: number; flicker: boolean }[]>([]);
  const shownAt = useRef(0);

  const run = () => {
    if (pending) return;
    // Spinner mounts on the same tick the request starts, and unmounts the moment it lands.
    setPending(true);
    shownAt.current = performance.now();
    window.setTimeout(() => {
      const shown = Math.round(performance.now() - shownAt.current);
      setPending(false);
      setLog((prev) => [{ shown, flicker: shown < 300 }, ...prev].slice(0, 5));
    }, LATENCY[speed]);
  };

  const flickers = log.filter((l) => l.flicker).length;

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="flex items-center gap-2 text-xs">
          <span className="text-muted-foreground">Server:</span>
          {(['fast', 'slow'] as Speed[]).map((s) => (
            <button
              key={s}
              type="button"
              onClick={() => setSpeed(s)}
              className={`rounded-md border border-border px-2 py-1 ${
                speed === s ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
              }`}
            >
              {s} ({LATENCY[s]}ms)
            </button>
          ))}
        </div>

        <button
          type="button"
          onClick={run}
          className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground"
        >
          Load results
        </button>

        <div className="flex h-20 items-center justify-center rounded-md border border-border bg-muted">
          {pending ? (
            <div
              aria-label="Loading"
              className="size-6 animate-spin rounded-full border-2 border-border border-t-primary"
            />
          ) : (
            <span className="text-xs text-muted-foreground">3 results</span>
          )}
        </div>

        <div className="space-y-1 text-xs tabular-nums">
          <div className="text-error">
            Sub-300ms flashes: {flickers} / {log.length}
          </div>
          {log.map((l, i) => (
            <div key={i} className={l.flicker ? 'text-error' : 'text-muted-foreground'}>
              spinner visible for {l.shown}ms {l.flicker ? '← strobe' : ''}
            </div>
          ))}
        </div>
      </div>
      <p className="text-xs text-error">
        No show-delay and no minimum visible time. On the fast server the spinner appears and
        vanishes inside ~60ms — click repeatedly and the panel strobes. The flash is pure noise: the
        response was already fast enough that nothing needed to be said.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-loading-state-duration-good`

```tsx
import { useEffect, useRef, useState } from 'react';

type Speed = 'fast' | 'slow';

const LATENCY: Record<Speed, number> = { fast: 60, slow: 1200 };
const SHOW_DELAY = 200; // don't show a spinner at all if we finish before this
const MIN_VISIBLE = 400; // once shown, keep it up at least this long

export function LoadingStateDurationGood() {
  const [speed, setSpeed] = useState<Speed>('fast');
  const [pending, setPending] = useState(false);
  const [spinner, setSpinner] = useState(false);
  const [log, setLog] = useState<{ latency: number; shown: number }[]>([]);
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach(window.clearTimeout), []);

  const run = () => {
    if (pending) return;
    setPending(true);

    const start = performance.now();
    let shownAt = 0;

    const showTimer = window.setTimeout(() => {
      shownAt = performance.now();
      setSpinner(true);
    }, SHOW_DELAY);
    timers.current.push(showTimer);

    const finish = () => {
      const latency = Math.round(performance.now() - start);
      window.clearTimeout(showTimer);

      // Never shown → land immediately. Already shown → hold it for MIN_VISIBLE.
      if (!shownAt) {
        setPending(false);
        setLog((prev) => [{ latency, shown: 0 }, ...prev].slice(0, 5));
        return;
      }
      const remaining = Math.max(0, MIN_VISIBLE - (performance.now() - shownAt));
      const holdTimer = window.setTimeout(() => {
        const shown = Math.round(performance.now() - shownAt);
        setSpinner(false);
        setPending(false);
        setLog((prev) => [{ latency, shown }, ...prev].slice(0, 5));
      }, remaining);
      timers.current.push(holdTimer);
    };

    const reqTimer = window.setTimeout(finish, LATENCY[speed]);
    timers.current.push(reqTimer);
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="flex items-center gap-2 text-xs">
          <span className="text-muted-foreground">Server:</span>
          {(['fast', 'slow'] as Speed[]).map((s) => (
            <button
              key={s}
              type="button"
              onClick={() => setSpeed(s)}
              className={`rounded-md border border-border px-2 py-1 ${
                speed === s ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
              }`}
            >
              {s} ({LATENCY[s]}ms)
            </button>
          ))}
        </div>

        <button
          type="button"
          onClick={run}
          className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground"
        >
          Load results
        </button>

        <div className="flex h-20 items-center justify-center rounded-md border border-border bg-muted">
          {spinner ? (
            <div
              aria-label="Loading"
              className="size-6 animate-spin rounded-full border-2 border-border border-t-primary"
            />
          ) : (
            <span className="text-xs text-muted-foreground">3 results</span>
          )}
        </div>

        <div className="space-y-1 text-xs tabular-nums">
          <div className="text-success">
            show-delay {SHOW_DELAY}ms · minimum visible {MIN_VISIBLE}ms
          </div>
          {log.map((l, i) => (
            <div key={i} className="text-muted-foreground">
              response {l.latency}ms →{' '}
              {l.shown === 0 ? 'no spinner at all' : `spinner visible ${l.shown}ms`}
            </div>
          ))}
        </div>
      </div>
      <p className="text-xs text-success">
        The fast server never renders a spinner — the work finished before the {SHOW_DELAY}ms
        show-delay elapsed. The slow one shows a spinner that stays put for at least {MIN_VISIBLE}ms,
        so it reads as a state rather than a glitch. Hammer the button on &ldquo;fast&rdquo;: nothing
        strobes.
      </p>
    </div>
  );
}
```

## References

- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [React: <Suspense>](https://react.dev/reference/react/Suspense)
- [Nielsen Norman: Response Time Limits](https://www.nngroup.com/articles/response-times-3-important-limits/)
