All articles
Design & UXJune 14, 2026 6 min read

Skeleton Screens vs Spinners: When Each One Actually Wins

Skeleton screens aren't automatically better than spinners. Here's the decision tree we use on real projects, with code, timing thresholds, and the accessibility traps nobody talks about.

Skeleton Screens vs Spinners: When Each One Actually Wins

Skeleton screens won the loading-state debate around 2018 and have been the default reflex ever since. The problem: somewhere between then and now, teams started slapping shimmering grey blocks on every async boundary in the app, including ones where a plain spinner — or nothing at all — would feel faster. This is the decision framework we use when we audit loading states for clients, plus the code and accessibility details that usually get missed.

The mental model: perceived duration, not actual duration

Loading UX is about perception. Two requests that both take 800ms can feel completely different depending on what the user sees. The research most teams cite (Facebook's original skeleton work, Luke Wroblewski's writeups, Nielsen Norman's response-time thresholds) converges on a few rough bands we treat as design constraints:

  • Under ~100ms: show nothing. The UI feels instant. Any loading indicator at this duration adds flicker and makes things feel slower.
  • 100ms – 400ms: a subtle inline indicator is fine. A full skeleton is overkill and will often flash in and out.
  • 400ms – 2s: this is where the spinner-vs-skeleton question actually matters.
  • Over ~2s: you need progress communication, not just a loading state. Steps, percentages, or streamed partial content.

If you remember one thing: the question isn't "skeleton or spinner?" It's "what does the user expect to see in this exact spot, and how long until they see it?"

When skeletons win

Skeletons work when three conditions hold:

  1. The layout is predictable — you know roughly what shape the content will take.
  2. The content is structural — a card grid, a list, a profile header, an article body.
  3. The wait is long enough to matter (roughly 400ms+) but short enough to feel like one event (under ~2s).

A product grid on an e-commerce category page is the textbook case. The user already knows they're getting cards. Showing the card outlines tells them "yes, you're in the right place, content is coming, and here's how much of it." That's a real cognitive win. It also prevents layout shift when the data lands, which helps your CLS score and stops users from mis-tapping.

A skeleton that doesn't lie

Most skeletons we see in audits commit one of two sins: they don't match the real content shape, or they animate forever and become visual noise. Here's a Tailwind + React pattern we keep coming back to:

function ProductCardSkeleton() {
  return (
    <div
      className="rounded-2xl border border-neutral-200 p-4"
      role="status"
      aria-label="Loading product"
    >
      <div className="aspect-square w-full animate-pulse rounded-xl bg-neutral-200" />
      <div className="mt-4 h-4 w-3/4 animate-pulse rounded bg-neutral-200" />
      <div className="mt-2 h-4 w-1/2 animate-pulse rounded bg-neutral-200" />
      <div className="mt-4 h-5 w-1/3 animate-pulse rounded bg-neutral-200" />
      <span className="sr-only">Loading…</span>
    </div>
  );
}

Three things to notice. The skeleton mirrors the real card's padding, radius, and rough proportions — when the data swaps in, nothing jumps. The animate-pulse is gentle, not a shimmering gradient that competes for attention. And there's an accessible label so screen readers announce something, not silence.

When spinners win

Spinners are still the right answer more often than current design Twitter would admit. Use a spinner when:

  • The action is user-initiated and discrete — submitting a form, confirming a payment, deleting a record.
  • The result will replace the current view, not fill in unknown content.
  • You don't know the shape of what's coming back (e.g. a search that might return 0, 1, or 200 results of different types).
  • The component is small — an icon button, an inline status pill.

A spinner on a "Save" button isn't a fallback — it's the correct choice. A skeleton there would be absurd. Same for a checkout's "Processing payment" state, where the user needs to know something is happening on the server more than they need to predict what the next screen looks like.

We see teams over-engineer this constantly. A modal that loads in 300ms doesn't need a five-element skeleton. A spinner — or honestly, just disabling the trigger and letting the modal appear when ready — is cleaner.

When neither wins: the optimistic path

The loading state nobody talks about is the one you skip entirely. If you can render the new state optimistically and roll back on failure, you've turned a 600ms wait into 0ms. Likes, follows, adding to cart, toggling settings, reordering lists — all candidates.

async function toggleLike(postId: string) {
  // 1. Update UI immediately
  setLiked(true);
  setCount((c) => c + 1);

  try {
    await api.like(postId);
  } catch (err) {
    // 2. Roll back and tell the user
    setLiked(false);
    setCount((c) => c - 1);
    toast.error("Couldn't save your like. Try again?");
  }
}

This isn't a loading-state trick, it's a loading-state deletion. The best UI is the one that doesn't need a spinner because the user never perceived a wait.

The accessibility layer most teams skip

Loading states are an accessibility minefield. A few rules we enforce in code review:

Announce state changes, but don't spam

Use aria-busy="true" on the container that's loading, and aria-live="polite" on the region where results will appear. Don't put aria-live on the skeleton itself — when the real content swaps in, the screen reader announces nothing because the live region got replaced.

<section aria-live="polite" aria-busy={isLoading}>
  {isLoading ? <ProductGridSkeleton /> : <ProductGrid items={data} />}
</section>

Respect reduced motion

Shimmering and pulsing animations can trigger vestibular issues. Gate them:

@media (prefers-reduced-motion: reduce) {
  .animate-pulse {
    animation: none;
    opacity: 0.6;
  }
}

Tailwind users get this almost for free with the motion-safe: variant — apply animations only inside it.

Don't trap focus during loads

If a modal is loading and you disable everything, make sure the user can still press Escape. We've seen production checkouts where a hung request locked users out of their own keyboard.

The hybrid pattern: spinner-on-skeleton

For longer loads (1.5s+) where you've committed to a skeleton, layer a small spinner on top after a delay. This handles the "is it broken or just slow?" anxiety that skeletons alone can't address.

function DelayedSpinner({ delay = 1500 }) {
  const [show, setShow] = useState(false);
  useEffect(() => {
    const t = setTimeout(() => setShow(true), delay);
    return () => clearTimeout(t);
  }, [delay]);
  if (!show) return null;
  return <Spinner aria-label="Still loading" />;
}

Pair it with a timeout that, after ~8 seconds, swaps the spinner for an error state with a retry button. Silent infinite skeletons are one of the worst patterns on the modern web.

A quick decision table

ScenarioUse
Submit button, 200–800msSpinner in button
Card grid / list, 400ms–2s, known shapeSkeleton matching layout
Search results with unknown shapeSpinner + result count message
Like, follow, toggleOptimistic, no indicator
Page navigation, 100–300msTop progress bar
Long job, 2s+Progress steps or streamed content
Anything under 100msNothing

Where we'd start

If you're auditing an existing product, do this in one afternoon. Open Chrome DevTools, throttle to Slow 4G, and click through your top five user flows. Every time you see a loading state, ask: does this match the rules above, or did someone reach for the default? You'll usually find a button spinner that should be optimistic, a skeleton on a modal that opens in 250ms, and at least one infinite spinner with no timeout. Fix those three categories first — they cover most of the perceived-performance wins without touching your actual backend latency.

For new work, write the loading states into the component spec alongside the empty and error states. If your design system doesn't already have a documented <LoadingBoundary> with these decisions baked in, that's the right place to put a week of effort. It pays back every sprint after.

#UX#Performance#Accessibility#Frontend

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project