How to Fix Layout Shift from Skeleton Screens That Do Not Match Real Content
This guide covers a counter-intuitive CLS source, within Reducing Cumulative Layout Shift (CLS) in Core Web Vitals & Measurement: the skeleton screen added specifically to prevent layout shift turns out to cause it.
Skeletons work by occupying the space content will fill. They fail when the space they occupy is not the space the content needs. A skeleton card 280px tall replaced by a product card 340px tall moves everything below it by 60px — multiplied across a grid of 12 cards, the lower half of the page jumps. Because the swap happens when data arrives, long after any user input, the movement counts toward CLS in full. Teams often see CLS rise after introducing skeletons, then conclude skeletons are bad. The problem is sizing, not the pattern.
Rapid Diagnosis
- Throttle the data request. In DevTools, use request blocking or a slow custom network profile on the API call alone, so the skeleton stays visible long enough to compare with the final layout.
- Screenshot both states at the same scroll position. Overlay them (or toggle quickly) and note which edges move.
- Read the shift sources. In the Performance panel, select the layout shift and inspect
previousRectandcurrentRectfor each source node. A consistent height delta per card points at skeleton sizing. - Check variable-length content. Titles that wrap to one, two or three lines, optional badges, and prices with or without a discount line all change card height.
- Check responsive breakpoints. A skeleton tuned for desktop often mismatches on mobile, where text wraps more.
Root Cause Analysis
1. Generic placeholder dimensions. Skeleton components shared across the app use fixed heights that approximate "a card" rather than the specific card that will render.
2. Variable content height. Real content height depends on data: text length, optional fields, image aspect ratios. A skeleton can only match it if the variability is constrained.
3. Images without intrinsic ratio. The skeleton reserves a 1:1 box, but the real image component renders at its natural aspect ratio once loaded, or has no reserved ratio and collapses to zero until it loads.
4. Different element counts. A skeleton list renders 6 placeholders; the API returns 9 items. The extra three rows push the footer down. Or it returns 2, and the page shrinks — content below moves up, which also counts.
Step-by-Step Resolution
1. Build skeletons from the same layout as the real component
Render the real component's layout with placeholder content, rather than a separate skeleton component. Every box keeps its real dimensions; only the content is replaced by shimmer.
function ProductCard({ product }) {
const loading = !product;
return (
<article className="card" aria-busy={loading}>
<div className="card-media">{loading ? <span className="shimmer" /> : <img {...product.image} />}</div>
<h3 className="card-title">{loading ? <span className="shimmer-text" /> : product.name}</h3>
<p className="card-price">{loading ? <span className="shimmer-text short" /> : product.price}</p>
</article>
);
}
// trade-off: one component handling both states adds a branch to every field.
// For very complex cards, a separate skeleton is fine IF it imports the same
// layout CSS and its dimensions are covered by a visual regression test.
Expected outcome: skeleton and content share CSS, so geometry can only diverge where content length varies.
2. Constrain variable-length content
Reserve space for the maximum lines you will show, and clamp longer text, so card height is fixed regardless of data.
.card-media { aspect-ratio: 4 / 3; } /* fixed image box */
.card-title {
min-height: calc(2 * 1.3em); /* always two lines tall */
line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.card-price { min-height: 2.75rem; } /* room for price + "was" price */
/* trade-off: line-clamp truncates long names. Expose the full name via the
title attribute or on the product page; never clamp text that carries
information users need to choose (sizes, variants). */
Expected outcome: real cards render at exactly the skeleton height; per-card shifts disappear.
3. Render the right number of placeholders
Request the count you will show (page size) and render that many skeletons. If the final count is unknown, contain the list so a different count cannot move content outside it.
.results { min-height: calc(3 * var(--card-row-height)); contain: layout; }
/* trade-off: a min-height for three rows leaves empty space when there are
fewer results. Show an explicit "No more results" message to fill it
rather than letting the page collapse. */
Expected outcome: the footer and anything below the list stay put whether the API returns 2 or 12 items within the reserved range.
4. Swap without changing scroll anchoring
When content replaces skeletons above the viewport (the user has scrolled down), browsers' scroll anchoring usually compensates. Make sure you have not disabled it with overflow-anchor: none, and avoid removing and re-adding the whole list — update in place so the anchor node survives.
Expected outcome: content above the viewport can change height without moving what the user is looking at.
Verification
Add a visual regression test that renders each skeleton and the corresponding loaded component with fixture data at three breakpoints, and asserts their bounding boxes are equal. That catches drift as designs change.
// Playwright: compare skeleton and loaded card geometry at a mobile width.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/test/product-card?state=loading');
const before = await page.locator('.card').first().boundingBox();
await page.goto('/test/product-card?state=loaded&fixture=long-title');
const after = await page.locator('.card').first().boundingBox();
expect(Math.abs(after.height - before.height)).toBeLessThan(1);
// trade-off: fixture-based tests only cover the fixtures you write. Include
// the longest real product name and the "with discount" variant.
In the field, attribute CLS with the web-vitals attribution build; largestShiftTarget should stop pointing at list containers and cards once the fix is live.
When Skeletons Are the Wrong Tool
Skeletons are best for content whose shape is known and stable: product cards, profile headers, table rows. They are a poor fit for content of genuinely unknown shape — a rich-text article body, user-generated posts with optional media, search results mixing several card types. For those, two alternatives usually work better. Either render nothing in that region until data arrives while containing it (contain: layout with a fixed min-height) so nothing outside can move, or fetch the data on the server and remove the loading state from the initial render entirely. Server rendering also helps LCP, since the content is in the first paint rather than a client-side swap. Reserve skeletons for repeat interactions in client-rendered views, where they make waiting feel shorter without costing stability.
FAQ
Do shimmer animations affect performance?
A shimmer implemented with a transform-animated gradient overlay is composited and cheap. One implemented by animating background-position across many elements triggers paint every frame and can cost noticeable main-thread and GPU time on low-end devices — enough to delay the very data rendering it is waiting for. Prefer a single transformed pseudo-element, and stop the animation under prefers-reduced-motion.
Should skeletons appear immediately or after a delay?
Show them only if loading takes longer than roughly 200–300ms. For faster responses, a skeleton that flashes for one frame is visual noise. Delaying them does not affect CLS as long as the reserved space exists from the first render — reserve the space immediately, show the shimmer after the delay.
How do I size skeletons for content whose length varies a lot?
Pick a reserved size from the distribution of real content, not the maximum. Measure the rendered height of a few hundred real items at each breakpoint and reserve the 75th–90th percentile height, then clamp anything longer. That keeps nearly every swap shift-free without leaving large gaps for short items. For truly unbounded content such as user reviews, reserve space only for the first screenful and let the rest expand below the fold, where it cannot move anything the user is looking at.
Related
- Reserving space for embeds with aspect-ratio — the same reservation principle for iframes and media.
- Fixing CLS on infinite scroll and load more — skeletons at the end of growing lists.
- Why SPA CLS accumulates across routes — skeleton swaps on every client-side navigation.