Skeleton Screens Lie Differently Than Spinners
Skeletons and spinners make different promises. Breaking the skeleton's promise costs more. Here's how each contract works and when to pick one over the other.
Skeleton screens and spinners are both loading states, but they make different promises to the user. A spinner says "something is happening, wait." A skeleton says "content is coming, it will look approximately like this." That second promise is a spatial commitment. If you break it — if the real content is nothing like the skeleton you showed — users experience a layout lurch that feels worse than a spinner would have.
Understanding which lie is acceptable in which context is the whole skill.
The Spinner's Contract
A spinner is honest about its ignorance. It doesn't know what's coming. It doesn't know the shape, the amount, or the structure. It just knows the system is doing something. This is low-commitment and versatile. A spinner works for form submissions, file uploads, navigation between unknown content, and any async operation where the result is unpredictable.
The spinner's weakness: when it runs for more than two to three seconds, users start to wonder if something broke. It gives no sense of progress. It doesn't differentiate between "almost done" and "just started." For long-running operations, a spinner needs to be paired with some kind of progress indication or it loses the user's trust.
The Skeleton's Contract
A skeleton screen implies structural knowledge. By showing the user a wireframe of the content — a title-width bar, three line-height bars for body text, a circle for an avatar — you're making a spatial promise: what loads will fit here. The skeleton sets a spatial expectation.
When your skeleton shows three list items and the API returns seventeen, the layout jumps. When you show a skeleton with a sidebar that the real content doesn't have, the page reflows on load. These are broken promises, and they produce a more jarring experience than a spinner would have because the user had already started mentally mapping the page.
When Skeletons Are Worse
Skeletons are the wrong choice when:
The content shape is unknown. A search results page where the first result might be a video, a product card, a knowledge panel, or a news article — you don't know the shape, so you can't make the spatial promise. A spinner is more honest here.
Content is loading quickly. A skeleton that flashes in and out in under 150ms is worse than nothing. It's visual noise. If your p50 response time is under 200ms, skip the skeleton entirely. Show nothing, then show the content. The brain doesn't register loading states it can't track.
The page has many independently loading sections. Six skeletons animating simultaneously looks chaotic. Consider whether a single spinner per major section is cleaner, or whether you can sequence the loads so the page builds from top to bottom.
Building a Skeleton That Keeps Its Promise
The skeleton's dimensions should come from the same layout constraints as the real content. If the real content uses line-height: 1.5 and font-size: 1rem, the skeleton line should be 1rem * 1.5 = 1.5rem tall. If the avatar is 40px, the circle is 40px.
function SkeletonLine({ width = "100%" }: { width?: string }) {
return (
<span
style={{
display: "block",
height: "1em",
width,
borderRadius: "4px",
background: "var(--color-skeleton-base)",
animation: "shimmer 1.5s ease-in-out infinite",
}}
/>
);
}
function ArticleCardSkeleton() {
return (
<article style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<SkeletonLine width="60%" /> {/* Title — shorter than full width */}
<SkeletonLine width="100%" /> {/* Body line 1 */}
<SkeletonLine width="85%" /> {/* Body line 2 — shorter to feel natural */}
<SkeletonLine width="40%" /> {/* Byline */}
</article>
);
}
@keyframes shimmer {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
The varying widths matter. A skeleton where every line is the same width looks like a placeholder, not a content approximation. Vary them as real text would vary — the last line of a paragraph is shorter.
The Transition Out
The skeleton-to-content swap is where most implementations fall apart. If the real content dimensions differ even slightly from the skeleton, the swap causes a visible jump.
Two approaches reduce this: opacity fade-out of the skeleton while the content fades in (hides the layout shift visually), or constraining the real content to the same container dimensions until it's rendered. The second approach is cleaner when you can control the content dimensions, but the fade works as a practical fix for most cases.
What doesn't work: an instant, synchronous swap with no transition at all. The jump is jarring even when the content perfectly matches the skeleton.
Make the swap as invisible as the loading state was visible.