Skeleton Screens vs Spinners: What Actually Works in 2026
Skeleton screens became the default loading pattern years ago, but most implementations make perceived performance worse, not better. Here's when to use what, and how to build skeletons that don't lie to your users.

Skeleton screens won the loading-state war around 2018, and ever since, teams have slapped grey rectangles on every async boundary without asking whether they actually help. They often don't. In some cases, they make your app feel slower than a plain spinner would.
This is a guide to picking the right loading pattern in 2026 — based on what the research actually says, what we've shipped, and what we've had to rip out.
The Three Patterns, Honestly Described
Before the decision tree, let's be precise about what each pattern signals to a user.
Spinners say: something is happening, I don't know how long it'll take, please wait. They're honest about uncertainty. The downside is they focus attention on the wait itself.
Skeleton screens say: content is coming, and it'll look roughly like this. They reduce uncertainty by previewing structure. The downside is they imply a promise — if the real content looks nothing like the skeleton, users feel tricked.
Nothing at all (or content-first rendering) says: here's what I have, more is on the way. This works when the initial paint is meaningful on its own and subsequent loads are fast enough to feel instant.
Most teams reach for skeletons by default. That's the bug.
When Skeletons Genuinely Help
Skeleton screens win in a narrow band of conditions. All of these need to be true:
- The load takes between roughly 400ms and 3 seconds. Shorter and the skeleton flashes; longer and users start to suspect something's broken regardless.
- The resulting layout is predictable — a feed of cards, a profile page, a product grid. Not a dashboard with conditional widgets.
- The skeleton matches the real layout closely. Same number of rows, similar block sizes, similar rhythm.
- The page is content-heavy, not action-heavy. Skeletons for a form or a settings page are usually pointless.
If any of these fail, you're better off with a spinner, a progress indicator, or — best of all — an architectural change that removes the wait.
The Layout-Match Problem
The most common skeleton failure mode: the skeleton shows four rows of equal height, then the real content renders as two short rows and one tall one with an image. The page jolts. Cumulative Layout Shift spikes. Users blame the app.
A skeleton that doesn't match the real layout is worse than no skeleton, because it sets an expectation and breaks it. If your content is genuinely variable, either generate skeletons from a cached shape on the previous visit, or use a spinner.
When Spinners Are the Right Call
Spinners get unfairly maligned. They're the correct pattern when:
- The wait is under 400ms — a skeleton would just flash.
- The wait is highly variable and might be long (3s+). Skeletons during long waits feel like the app is frozen pretending to be busy.
- The user just triggered an action (submitted a form, clicked "pay"). Action waits want a focused, attention-pulling indicator, not a structural preview.
- The layout is unpredictable — search results that might be empty, a dashboard whose widgets depend on user config.
A good spinner is small, centred in the affected region, and accompanied by a label when the wait is likely to exceed about a second ("Processing payment..."). The label matters more than the animation.
When to Show Nothing
This is the underused option. If you can render meaningful content from cache, local state, or a static shell in under 100ms, just do that and stream the rest in. No skeleton, no spinner. Users perceive sub-100ms transitions as instant, and you've spent zero design budget on loading affordances.
This is where modern frameworks earn their keep. React Server Components, Next.js streaming, Remix's deferred loaders, and SvelteKit's streamed responses all let you ship a fast first paint and resolve slower data in place. The pattern is:
- Render the shell and any cached or fast data immediately.
- Stream slower regions in as they resolve.
- Use a skeleton only for the regions that are still pending after ~400ms.
// Next.js app router example
import { Suspense } from 'react';
export default function ProductPage({ params }) {
return (
<main>
{/* Fast: from cache or edge */}
<ProductHeader id={params.id} />
{/* Slow: live inventory, personalised pricing */}
<Suspense fallback={<InventorySkeleton />}>
<LiveInventory id={params.id} />
</Suspense>
{/* Slow and non-critical: stream in, no skeleton */}
<Suspense fallback={null}>
<Recommendations id={params.id} />
</Suspense>
</main>
);
}
Notice we're using three different strategies on one page. That's the point. "Always skeleton" and "always spinner" are both wrong.
Building a Skeleton That Doesn't Lie
If you've decided a skeleton is right, here's how to make one that helps instead of hurts.
Match the Real Layout Precisely
Measure your actual content. If product cards are 320px tall with an 80px image, a 24px title, and two 16px metadata lines, your skeleton should be exactly that. Don't eyeball it.
.skeleton-card {
height: 320px;
display: grid;
grid-template-rows: 80px 24px 16px 16px;
gap: 8px;
padding: 16px;
}
.skeleton-block {
background: linear-gradient(
90deg,
var(--skeleton-base) 0%,
var(--skeleton-highlight) 50%,
var(--skeleton-base) 100%
);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton-block { animation: none; }
}
The prefers-reduced-motion block is non-negotiable. Shimmer animations trigger vestibular discomfort in some users, and a static skeleton communicates the same information.
Use Tokens, Not Hardcoded Greys
Skeleton colours should come from your design tokens, with a base and a highlight that adapts to light and dark modes. A #eee skeleton in dark mode looks broken.
:root {
--skeleton-base: oklch(94% 0 0);
--skeleton-highlight: oklch(97% 0 0);
}
[data-theme='dark'] {
--skeleton-base: oklch(22% 0 0);
--skeleton-highlight: oklch(28% 0 0);
}
Get the Accessibility Right
Screen readers shouldn't announce a wall of empty boxes. Wrap skeletons in a region with aria-busy="true" and aria-live="polite", and provide a text label:
<section aria-busy="true" aria-live="polite" aria-label="Loading products">
<div class="skeleton-card" aria-hidden="true">...</div>
<div class="skeleton-card" aria-hidden="true">...</div>
</section>
When real content arrives, flip aria-busy to false and the live region announces the update. The individual skeleton blocks are aria-hidden so they don't pollute the accessibility tree.
The Perceived Performance Trade-off
There's research going back to Facebook's original 2013 skeleton experiments suggesting skeletons make waits feel shorter. That research is now over a decade old, and the conditions it tested (slow mobile networks, server-rendered HTML, no streaming) barely exist in the same form anymore.
In our experience shipping production apps in the last couple of years, the bigger wins come from:
- Reducing the wait itself with caching, edge rendering, and optimistic UI.
- Showing partial real content instead of skeletons — even one cached field beats a skeleton.
- Designing the first paint to be meaningful, so loading affordances are needed only for secondary regions.
Skeletons are a patch on a slow experience. Treat them as such. If you're reaching for them constantly, the question isn't "how do I make better skeletons," it's "why is so much of my UI async?"
A Decision Cheat Sheet
- Wait under 400ms → show nothing, render when ready.
- Wait 400ms–3s, predictable layout, content-heavy page → skeleton.
- Wait 400ms–3s, unpredictable layout or action-triggered → spinner with label.
- Wait over 3s → progress indicator if you can estimate, otherwise spinner with explanatory text.
- Any wait where you have cached or partial data → show the partial data, skeleton only the missing bits.
Where We'd Start
If you're auditing an existing app, open it on a throttled connection and screenshot every loading state. Two questions for each one: does the skeleton match what renders next, and could we have shown real content instead? You'll usually find at least a third of your skeletons are doing nothing useful, and a handful are actively misleading.
From there, the work is unglamorous — measure real layouts, wire up streaming where your framework supports it, and reserve skeletons for the cases where they genuinely earn their place. If you want a hand with that audit or a deeper look at your loading architecture, our design and engineering team does this work for product teams every week.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Focus Rings Are Not Optional: A Practical Guide to Visible Focus in 2026
Removing the focus ring is the most common accessibility regression we see in audits. Here's how to keep keyboard users happy without making your designer cry.

Toast Notifications Are Lying to Your Users
Toasts feel modern, but they're quietly failing the people who need them most. Here's how we audit, fix, or replace them — with patterns that hold up under accessibility and conversion scrutiny.

Disabled Buttons Are a UX Bug: What to Ship Instead
Greyed-out buttons feel safe to designers and ship-ready to engineers. They're neither. Here's why disabled states quietly tank conversion, and the patterns we use to replace them without breaking validation.
