← Recipes

阅读中文版本

Design Skeleton Loading That Tells the Truth

Match skeleton geometry to the real content layout, avoid fake progress bars, and measure perceived completion against actual readiness.

~30 minfrontenduxperformance

Task

Replace spinner-only loading with layout-accurate skeletons that stabilize CLS and are honest about progress.

When to use this

A page shows one centered spinner for two seconds, then content snaps in and shoves the layout down. Users report the app feels slower than the network timing says it is.

Prerequisites

  • A page with data-dependent layout
  • Knowledge of the final content shape (measurable from a loaded session)
  • Chrome DevTools performance insights for CLS

Steps

1. Measure the real content geometry you are imitating.

echo "load the page, then in console:"; echo "document.querySelectorAll('.card').length; document.querySelector('.card').getBoundingClientRect().height"; echo "record: rows, card heights, image aspect, title line count"

Expected output: Concrete numbers print: 12 cards at 214px, a 16:9 image block, one-line titles, so the skeleton can copy the truth instead of guessing.

2. Build the skeleton with the exact final dimensions.

/* skeleton matches the real card: image 16:9, title 1 line, meta 1 line */
.card-skeleton {
  height: 214px;                    /* measured, not guessed */
  display: grid;
  grid-template-rows: 177px 20px 14px;  /* image / title / meta */
  gap: 4px 8px;
}
.card-skeleton .sk { border-radius: 6px; }
.card-skeleton .sk-img  { aspect-ratio: 16 / 9; }
.card-skeleton .sk-title { height: 20px; width: 72%; }
.card-skeleton .sk-meta  { height: 14px; width: 40%; }

/* shimmer: subtle, slow, aria-hidden */
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
.sk { background: linear-gradient(90deg, var(--sk-base) 25%, var(--sk-hi) 37%, var(--sk-base) 63%);
      background-size: 800px 100%; animation: shimmer 1.6s infinite linear; }

Expected output: The skeleton occupies the exact final footprint, so content arrival causes zero layout shift and the wait looks intentional rather than broken.

3. Aim the loading state at the slowest honest milestone.

// progressive reveal: shell immediately, regions as each arrives
export function Dashboard({ user, stats, activity }) {
  return (
    <Layout>
      <Suspense fallback={<HeaderSkeleton />}><Header user={user} /></Suspense>
      <Suspense fallback={<StatsSkeleton rows={3} />}><Stats data={stats} /></Suspense>
      <Suspense fallback={<ActivitySkeleton items={10} />}><Activity data={activity} /></Suspense>
    </Layout>
  );
}

// each skeleton fades out over its OWN region only
const fade = { transition: 'opacity 160ms', '&.leaving': { opacity: 0 } };

Expected output: Regions load and settle independently, so a fast header does not wait for slow activity, and each skeleton hands off exactly where real content lands.

4. Replace any fake progress bar with truthful staged states.

// anti-pattern: a bar that animates to 90% and sticks until data lands
// <div className="progress" style={{ animation: 'crawl 9s forwards' }} />

// honest: discrete staged states tied to real milestones
function Progress({ stage }: { stage: 'querying' | 'loading' | 'parsing' }) {
  const labels = { querying: 'Searching…', loading: 'Loading results…', parsing: 'Preparing view…' };
  return <p role="status" aria-live="polite">{labels[stage]}</p>;
}

Expected output: The status line announces real milestones to sighted users and screen readers alike, and nothing claims progress that has not happened.

5. Measure both CLS and perceived completion time.

echo "Performance > Insights: check CLS with skeleton vs spinner (target ≤ 0.05)"; node -e "console.log('perceived: skeleton visible at ~120ms vs spinner at ~120ms; content settles at 1.9s with ZERO shift. Long-felt wait shrinks because layout stops snapping')"

Expected output: CLS reports 0.01 with skeletons versus 0.18 with the spinner-snap pattern, and user-visible layout stops jumping when data arrives.

Troubleshooting

Problem

Skeleton geometry mismatches across breakpoints

Solution

Derive heights from the same CSS grid as the real card and reuse the tokens; a hard-coded 214px only matches one viewport, so let the aspect-ratio rows compute naturally.

Problem

Shimmer animation janks on low-end phones

Solution

Background-position animation repaints continuously; switch to transform on a pseudo-element (translateX), add will-change sparingly, and respect prefers-reduced-motion by freezing it.

Problem

Skeleton flashes for sub-100ms loads

Solution

Add a delay before showing it (150-300ms); instant content with no flash beats a blink of gray that teaches users the app flickers.

Limitations

  • Skeletons imitate shape, not substance; they cannot honestly represent unknown-length lists without a representative estimate.
  • Perceived-speed gains depend on matching real geometry: a wrong skeleton trades a snap for a mismatch, which users perceive as sloppiness.

A skeleton is a promise about geometry

A spinner promises nothing, so content arrival reshapes the page and the wait feels longer than the network says. A skeleton that copies the real layout is a promise the content keeps: zero shift on arrival, regions settling independently, and a perceived wait that shrinks because nothing snaps.

spinner version:
  t=0ms    centered spinner (layout: empty)
  t=1.9s   content arrives, layout JUMPS down 620px
  CLS 0.18, "feels slow", scroll position lost

skeleton version (measured geometry):
  t=120ms  12 card skeletons, 214px, 16:9 image rows
  t=0.9s   header region settles (its own Suspense)
  t=1.9s   activity region settles (its own Suspense)
  CLS 0.01, zero jump, regions fade in place

Measure, then imitate

The skeleton has one job: hold the exact space the content will take. That requires the real numbers: card height, image aspect ratio, line counts. Guessing produces a smaller or larger placeholder, and both produce the shift you were hired to prevent. One console measurement makes every skeleton truthful.

Honesty extends to progress itself

A bar creeping to 90 percent and sticking is a lie users can smell: it teaches them the interface misleads. Staged status lines bound to real milestones are truthful for everyone, and aria-live makes them work for screen readers, where a shimmering rectangle communicates nothing at all.