# Loading Copy Sets an Expectation

**SHOULD** · **ID:** `content-impeccable-loading-copy-expectation` · **Category:** content
**Source:** [impeccable](https://impeccable.style/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-loading-copy-expectation

> SHOULD: Name the work and its expected duration inside a loading state ("Analyzing your data… this usually takes 30–60 seconds") instead of a bare "Loading…", which says the same thing at 1s and at 90s so the user cannot tell working from hung. Show a signal that advances, and give anything past ~10s an escape hatch.

Name the work being done and how long it usually takes, instead of shipping a bare "Loading…"

The corpus already governs how a loading state looks — stable skeletons, matched dimensions, a minimum display duration — but nothing until now governed the words inside it, and the words are what carry the expectation. "Loading…" is a dead end: it says the same thing at one second and at ninety, so on a long job the user cannot distinguish "still working" from "hung", and the rational move becomes reloading the page and losing the work. Name the work ("Importing 1,240 rows"), state the expected duration ("this usually takes about a minute"), and show a signal that advances. Anything past ten seconds also needs an escape hatch, because a bounded wait the user chose to abandon is far better than an unbounded one they had to guess about.

## Bad — do not do this

`content-impeccable-loading-copy-bad`

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

// The whole failure is the string. It never changes, and it never ends.
const LABEL = 'Loading…';

export function ImpeccableLoadingCopyBad() {
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    const id = window.setInterval(() => {
      setElapsed((s) => (s >= 90 ? 0 : s + 1));
    }, 1000);
    return () => window.clearInterval(id);
  }, []);

  const suspicious = elapsed >= 30;

  return (
    <div className="w-full max-w-md space-y-3">
      <div className="flex flex-col items-center gap-3 rounded-lg border border-border bg-card p-8">
        <div
          className="size-6 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none"
          aria-hidden="true"
        />
        <p className="text-sm text-foreground" aria-live="polite">
          {LABEL}
        </p>
        <p className="font-mono text-xs tabular-nums text-muted-foreground">
          {elapsed}s elapsed
        </p>
      </div>

      <p className="text-xs text-muted-foreground">
        No named work. No expected duration. No cancel. The copy says exactly the
        same thing at 1s and at 90s.
      </p>

      <p className="text-xs text-error">
        {suspicious
          ? `${elapsed}s in — is it working, or is it hung? The copy cannot tell you, so the user reloads and loses the job.`
          : 'Watch the counter: past ~30s the user has no way to tell "still working" from "stuck".'}
      </p>
    </div>
  );
}
```

## Good — do this

`content-impeccable-loading-copy-good`

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

const TOTAL_ROWS = 1240;
const EXPECTED = 'this usually takes about a minute';

export function ImpeccableLoadingCopyGood() {
  const [rows, setRows] = useState(0);
  const [cancelled, setCancelled] = useState(false);

  useEffect(() => {
    if (cancelled) return;
    const id = window.setInterval(() => {
      setRows((r) => (r >= TOTAL_ROWS ? 0 : Math.min(TOTAL_ROWS, r + 40)));
    }, 500);
    return () => window.clearInterval(id);
  }, [cancelled]);

  const percent = Math.round((rows / TOTAL_ROWS) * 100);

  if (cancelled) {
    return (
      <div className="w-full max-w-md space-y-3">
        <div className="space-y-2 rounded-lg border border-border bg-card p-6">
          <p className="text-sm font-medium text-foreground">Import cancelled</p>
          <p className="text-sm text-muted-foreground">
            {rows.toLocaleString()} of {TOTAL_ROWS.toLocaleString()} rows were
            imported. Nothing else changed.
          </p>
          <button
            type="button"
            onClick={() => {
              setRows(0);
              setCancelled(false);
            }}
            className="mt-2 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
          >
            Start import again
          </button>
        </div>
        <p className="text-xs text-success">
          The escape hatch is real, and it says what it left behind.
        </p>
      </div>
    );
  }

  return (
    <div className="w-full max-w-md space-y-3">
      <div className="space-y-3 rounded-lg border border-border bg-card p-6">
        <div className="space-y-1">
          <p className="text-sm font-medium text-foreground" aria-live="polite">
            Importing {TOTAL_ROWS.toLocaleString()} rows… {EXPECTED}
          </p>
          <p className="font-mono text-xs tabular-nums text-muted-foreground">
            {rows.toLocaleString()} / {TOTAL_ROWS.toLocaleString()} rows
          </p>
        </div>

        <div
          role="progressbar"
          aria-valuenow={percent}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-label="Import progress"
          className="h-2 w-full overflow-hidden rounded-full bg-muted"
        >
          <div
            className="h-full rounded-full bg-primary"
            style={{ width: `${percent}%` }}
          />
        </div>

        <button
          type="button"
          onClick={() => setCancelled(true)}
          className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
        >
          Cancel import
        </button>
      </div>

      <p className="text-xs text-success">
        The copy names the work (“Importing 1,240 rows”), sets the expectation
        (“about a minute”), and proves it is alive via the row count — so a slow
        job never reads as a hang.
      </p>
    </div>
  );
}
```

## References

- [impeccable — clarify](https://impeccable.style/)
- [NN/g: Progress Indicators](https://www.nngroup.com/articles/progress-indicators/)
- [NN/g: Response Times — The 3 Important Limits](https://www.nngroup.com/articles/response-times-3-important-limits/)
