Skip to story

Patterns Ux

When Skeletons Beat Spinners (Product)

8 min read · May 31, 2026 ★ Flagship

Reading level

The skeleton that makes loading feel slower

A skeleton screen replaced a spinner on a search results page. It showed four card-shaped gray boxes — but sometimes the search returned 0 results. The skeleton flashed for 1.2 seconds showing four cards, then replaced with "No results found." Users rated that experience as slower and more confusing than the spinner it replaced, even though the load time was identical.

Skeleton screens improve perceived performance by giving the brain a prediction to confirm. When the prediction is wrong — wrong number of items, wrong layout — the correction is cognitively jarring. The spinner would have been better.

The rule: skeletons work for known-shape content. A user profile always has an avatar, a name, and a bio — three elements, same every time. A skeleton for that is an accurate prediction. A feed with variable item counts, search with variable results, or any list that might return empty are unknown-shape content. Use a spinner.

The mechanism: skeleton screens exploit predictive coding in visual perception. The brain "pre-renders" the expected layout, and when actual content arrives and confirms the prediction, the perception is "fast." When content violates the prediction (different count, different layout), there's a mismatch event — cognitively similar to a layout shift — that degrades the perceived experience below baseline. Mismatched skeletons are measurably worse UX than spinners for the same load time.

The decision matrix

Use a skeleton when:

  • You know exactly what's loading (a profile, an article, a settings panel)
  • The number of items is fixed or predictable
  • The layout won't shift when content arrives

Use a spinner when:

  • The count of results is unknown (search, filtered lists)
  • The content might be empty
  • You're loading after a user action (button press, form submit)
  • The layout changes significantly based on what loads
// ❌ Skeleton for unknown-count results
function SearchResults({ query }) {
  if (isLoading) return <SkeletonCards count={4} />; // wrong if results ≠ 4
  if (results.length === 0) return <EmptyState />;
  return results.map(r => <ResultCard key={r.id} {...r} />);
}

// ✅ Spinner for unknown-count results
function SearchResults({ query }) {
  if (isLoading) return <Spinner label="Searching…" />;
  if (results.length === 0) return <EmptyState />;
  return results.map(r => <ResultCard key={r.id} {...r} />);
}

// ✅ Skeleton for known-shape profile
function UserProfile({ userId }) {
  if (isLoading) return <ProfileSkeleton />; // always: avatar + name + bio
  return <Profile user={user} />;
}

The 200ms delay rule applies to both: don't flash any indicator for loads under 200ms. A short flash is more distracting than no indicator at all.

Implementation detail that teams miss: the 200ms delay for skeletons. Without it, fast network loads produce a brief skeleton flash that looks like a bug.

function useDelayedLoading(isLoading, delay = 200) {
  const [showLoader, setShowLoader] = useState(false);
  useEffect(() => {
    if (!isLoading) { setShowLoader(false); return; }
    const id = setTimeout(() => setShowLoader(true), delay);
    return () => clearTimeout(id);
  }, [isLoading, delay]);
  return showLoader;
}

React 18's useTransition and Suspense with a delay prop handle this pattern natively — the fallback only renders if the transition takes longer than the configured timeout.

The settings panel that broke the skeleton rule

A dashboard team replaced all loading spinners with skeleton screens after reading that skeletons improve perceived performance. For the main feed — a list of activity cards — the change worked well. Skeleton cards appeared, real cards replaced them smoothly, users felt the page was fast. The team was pleased and applied the same treatment to every loading state in the product.

The user settings panel was one of those states. It loaded a single async value: the user's current notification preference (a radio button group). The skeleton screen for it showed five columns of shimmer, matching the general-purpose skeleton they'd built. After 200ms, the skeleton collapsed into a single radio group with three options. The layout shift was visually jarring — worse than any spinner had been. User complaints about the settings panel specifically increased after the skeleton rollout.

The skeleton was the wrong tool because the content shape was not known in advance. Five-column shimmer suggested five columns of content; three radio options produced one compact row. Any skeleton that does not precisely match the actual rendered content causes a layout prediction violation — which the brain perceives as a visual jolt more disruptive than a spinner, whose resolution shape is unpredictable by design. A spinner carries no shape prediction; a skeleton carries an explicit one. Wrong predictions are worse than no predictions.

The rule: known shape, known count, long enough to notice

The fix was to revert the settings panel to a centered spinner — small, shape-neutral, and appropriate for a 200–400ms single-value async load. The feed skeleton stayed. The team added an explicit decision rule: skeletons are only permitted when the skeleton's shape exactly matches the content shape, the item count is known or bounded, and the expected load time exceeds 600ms. Everything else uses a spinner.

A secondary rule emerged from user testing: loads under 400ms should show nothing at all, gated behind a 200ms delay. A skeleton that flashes for 150ms and disappears is more disruptive than a blank-then-content transition. The useDelayedLoading hook enforced this — no indicator rendered until the async operation had been pending for at least 200ms.

The discipline required is to categorise loading states before choosing an indicator. User profile: known shape (avatar, name, bio), predictable count (1), likely over 600ms on first load — skeleton is correct. Search results: unknown count, may be empty, shape depends on result type — spinner is correct. Form submit: immediate feedback needed, no content shape to predict — button loading state (spinner within the button) is correct. Applying these categories consistently prevents the settings-panel failure mode from recurring.

Pattern at a glance

Before — skeleton for settings panel (wrong shape, causes layout shift)
// ❌ Generic skeleton applied to all loading states
function SettingsPanel() {
  const { data, isLoading } = useNotificationPrefs();

  if (isLoading) {
    // Shows 5-column shimmer — wrong shape for a 3-option radio group
    return <SkeletonGrid columns={5} rows={2} />;
  }

  return <RadioGroup options={data.options} />;
  // Skeleton collapses to 1-row radio group → layout shift
}
After — spinner for settings (shape-neutral); skeleton retained for feed
// ✅ Settings panel: spinner (shape-neutral, short async load)
function SettingsPanel() {
  const { data, isLoading } = useNotificationPrefs();
  const showLoader = useDelayedLoading(isLoading, 200);

  if (showLoader) return <Spinner size="sm" label="Loading settings…" />;
  return <RadioGroup options={data.options} />;
}

// ✅ Feed: skeleton (known shape, known count, >600ms typical load)
function ActivityFeed() {
  const { data, isLoading } = useFeed();
  const showLoader = useDelayedLoading(isLoading, 200);

  if (showLoader) return <ActivityCardSkeleton count={5} />;
  return data.map(item => <ActivityCard key={item.id} {...item} />);
}

Try it: skeleton vs spinner for the same content

Both modes load the same three product cards with a 1.2s delay. The skeleton mode shows gray card shapes before the content loads. The spinner mode shows a spinning circle. Notice how the skeleton feels faster even though the time is identical.

Now click the "Empty results" variant. The skeleton mode shows four cards, then collapses to an empty state — the prediction is wrong. The spinner mode goes directly from spinning to empty state. Which feels more correct?

The mismatch case (skeleton predicts 4, gets 0) demonstrates the expectation-violation effect. Measure user ratings for the same load time across: correct skeleton, wrong skeleton, and spinner. Wrong skeleton reliably scores below spinner.

⚡ Interactive demo

Implementation depth

The skeleton shimmer animation should use background: linear-gradient animated with a CSS @keyframes that shifts the gradient position. The shimmer direction should match reading direction (left to right in LTR). Use animation-timing-function: ease-in-out and a 1.4–1.8s duration — faster shimmer feels erratic; slower feels static.

/* Skeleton shimmer — accessible, performant */
@keyframes shimmer {
  from { background-position: -200px 0; }
  to   { background-position: calc(200px + 100%) 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    var(--color-surface-2) 25%,
    var(--color-surface-3) 50%,
    var(--color-surface-2) 75%
  );
  background-size: 400px 100%;
  border-radius: 4px;
}

@media (prefers-reduced-motion: no-preference) {
  .skeleton { animation: shimmer 1.6s ease-in-out infinite; }
}
/* No animation in reduced-motion mode — static skeleton is fine */

Pitfalls in skeleton screen implementation:

  • Aria live regions on skeleton swap — when the skeleton is replaced by real content, screen readers should announce it. Wrap the loading region in aria-live="polite" and ensure the content update includes meaningful text, not just a visual change.
  • Skeleton count must match content count — if your feed sometimes returns 3 items and sometimes 8, a fixed 5-card skeleton creates a prediction violation on both counts. Use a single generic loading state for variable-count content, or derive count from a cached previous response.
  • CLS from skeleton-to-content transition — if the skeleton's dimensions don't exactly match the content's dimensions, the swap causes layout shift. Measure both states and ensure skeleton height equals content height. This often requires explicit min-height on the content container.
  • React 18 Suspense + delay<Suspense fallback={<Skeleton />}> with startTransition defers showing the fallback for fast loads, preventing skeleton flash. This is the idiomatic React 18 approach to the useDelayedLoading pattern.

References

Remember

Key takeaways

  • Skeleton screens only work when you know the shape of what's loading. If the content might be empty or a different count, use a spinner instead.
    The known-shape rule: profiles, articles, settings panels → skeleton. Search results, filtered lists, any variable-count content → spinner.
    Mismatched skeletons (predicted layout ≠ actual content) are measurably worse than spinners for the same load time. The expectation violation effect compounds the wait experience.
  • Add a 200ms delay before showing any loading indicator — fast loads shouldn't flash a skeleton or spinner at all.
    In React 18+, Suspense with a timeout handles the 200ms delay natively. useTransition prevents the UI from flashing back to a loading state for fast async updates.
    The cognitive mechanism: skeletons exploit predictive coding — the brain pre-renders the expected layout. Correct predictions feel fast; violated predictions feel slower than no prediction at all. Match the skeleton to the actual content shape exactly, or don't use one.

Enjoyed this case?

Case 1 of 4 in Patterns Ux · 17 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.