Build a Skeleton Loading State System

Skeleton loaders (gray boxes shaped like the eventual content) make slow apps feel faster. They appear in nearly every modern app — Facebook popularized them; Airbnb, Linear, and others made them ubiquitous. The interview tests whether you understand the patterns and the subtle UX considerations.

Why skeletons

Compared to a spinner:

  • Communicates that content will appear (not just “loading”)
  • Reserves space — no layout shift when content loads
  • Feels faster (perceived performance research)

When to use

  • Initial page load
  • Loading new data after navigation
  • Pagination loading more items
  • NOT for fast async (under 200ms — flash is annoying)

Implementation

Build a skeleton component matching the eventual layout:

function CardSkeleton() {
  return (
    <div className="card">
      <div className="skeleton" style={{ height: 200 }} />
      <div className="skeleton" style={{ width: '80%', height: 20, marginTop: 12 }} />
      <div className="skeleton" style={{ width: '60%', height: 16, marginTop: 8 }} />
    </div>
  );
}

Animation

Two common animations:

  • Shimmer: gradient sweeping across (most popular)
  • Pulse: opacity oscillates

CSS-only:

.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Match the layout

The skeleton should occupy the same space as the eventual content. When data loads, no layout shift.

For variable content (text of unknown length): use placeholder lines of typical length.

Number of skeleton items

For lists: render the typical number of items (e.g., 6 card skeletons for a grid that usually shows 6).

Don’t render 100 skeletons for a 100-item list. Match what the user will see in the first viewport.

Avoiding flash

If data loads in under 200ms, skeleton flashes briefly and looks broken. Strategies:

  • Delay skeleton render by 100ms; data may resolve before skeleton shows
  • If skeleton is visible, keep it visible for at least 300ms (avoid flash)

This is “min loading time” pattern. Counter-intuitive but improves perceived experience.

Skeleton + suspense

React Suspense pairs naturally with skeletons:

<Suspense fallback={<CardSkeleton />}>
  <Card />
</Suspense>

Skeleton vs spinner

  • Skeleton for content with known shape (cards, lists, articles)
  • Spinner for short async operations or unknown content
  • Progress bar for measurable progress (uploads, downloads)

Theme considerations

  • Light theme: gray (#e0e0e0 base, #f0f0f0 highlight)
  • Dark theme: appropriate dark grays
  • Reduce contrast slightly to avoid being more prominent than content

prefers-reduced-motion

Respect user preferences:

@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
}

Common antipatterns

  • Skeleton that does not match final layout (jarring shift on load)
  • Skeleton stays after content arrives (broken state machine)
  • Skeleton too detailed (looks like real content, confusing)
  • Animation too aggressive (distracting)

Frequently Asked Questions

Should every loading state have a skeleton?

No. Skeletons make sense for full-page or large-section loads. Inline async (button click → result) is fine with a small spinner.

How do I handle infinite scroll with skeletons?

Render a few skeleton cards at the bottom while loading the next page. Replace with real content as it arrives.

Are skeletons accessible?

Add aria-busy=”true” on the loading container. aria-live region announces when content arrives.

Scroll to Top