You're reading an article, about to tap a link, and suddenly the page shifts. You tap an ad instead. That's Cumulative Layout Shift (CLS) - one of Google's three Core Web Vitals alongside LCP and INP.
A good CLS score is under 0.1. Anything over 0.25 is poor. Unlike LCP, CLS is cumulative - every shift during the page's lifetime adds up.
The mistake I see most often is treating CLS like an image-only problem. Missing image dimensions are common, but the most expensive shifts on real pages often come from consent banners, ad slots, web fonts, review widgets, recommendation blocks, and client-rendered content that appears above the article body.
The CLS triage pattern I use
Start by separating load-time shifts from interaction-time shifts:
| Shift type | When it happens | Common cause | First place to look |
|---|---|---|---|
| Load-time CLS | Before the user interacts | Images, fonts, ads, banners, embeds | Lighthouse trace and DevTools Performance panel. |
| Post-load CLS | After the page appears idle | Late widgets, route hydration, delayed personalization | Real user monitoring or a manual interaction recording. |
| Interaction-adjacent shifts | Near clicks/taps | Expanding UI without reserved space, delayed state updates | Component states and skeleton dimensions. |
Only unexpected shifts count. A menu opening after a tap is usually fine. A newsletter banner pushing the article down three seconds after page load is not.
What causes layout shifts
A layout shift happens when visible content moves without user interaction. The browser calculates how much of the viewport was affected and how far things moved.
Common causes:
- Images without dimensions
- Ads or embeds that load late
- Web fonts causing text to reflow
- Dynamic content inserted above existing content
The key insight: shifts only count if they're unexpected. If a user clicks a button and content expands, that's fine. If content moves on its own, that's a problem.
Find your layout shifts
Option 1: DevTools
- Open Chrome DevTools
- Go to Performance tab
- Enable "Web Vitals" in settings
- Record a page load
- Look for red "Layout Shift" markers
Option 2: Layout Shift Debugger
Add this to your console to highlight shifts in real-time:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Layout shift:', entry);
}
}).observe({ type: 'layout-shift', buffered: true });
For a more useful console snippet, print the shifted elements as well:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
console.log('CLS contribution:', entry.value);
for (const source of entry.sources || []) {
console.log(source.node, {
previousRect: source.previousRect,
currentRect: source.currentRect,
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
That output is noisy, but it helps answer the important question: "what moved?" A low-level trace is much more actionable than a generic "avoid layout shifts" warning.
Option 3: PageSpeedFix
Run your URL through PageSpeedFix - we identify the specific elements causing shifts and prioritize them by impact.
The 4 main fixes
1. Always set image dimensions
When an image loads, the browser needs to know how much space to reserve. Without dimensions, it reserves zero space, then shifts everything when the image appears.
What to do:
Always include width and height attributes:
<img
src="photo.jpg"
width="800"
height="600"
alt="Photo"
/>
For responsive images, use CSS aspect-ratio:
img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
}
In Next.js, the Image component handles this automatically:
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />
In Nuxt:
<NuxtImg src="/photo.jpg" width="800" height="600" />
If the design uses cards with variable image crops, set the stable ratio on the wrapper instead of letting each image decide the row height:
.article-card__media {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.article-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
}
This is especially useful for blog indexes, product grids, recipe cards, and any page where one late image can push an entire row down.
2. Reserve space for ads and embeds
Ads are notorious for causing layout shifts because they load asynchronously and often have variable heights.
What to do:
Create a container with minimum dimensions:
.ad-container {
min-height: 250px; /* Standard ad height */
background: #f0f0f0;
}
For responsive ads, reserve the largest likely slot for that breakpoint and keep the label outside the moving area:
<aside class="ad-slot" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<div class="ad-box"></div>
</aside>
.ad-slot {
margin: 2rem 0;
}
.ad-label {
display: block;
min-height: 1rem;
font-size: 0.75rem;
}
.ad-box {
min-height: 280px;
}
@media (min-width: 768px) {
.ad-box {
min-height: 250px;
}
}
This is also safer for AdSense review because the ad area is clearly identified and does not jump into content after the user starts reading.
For embeds like YouTube or Twitter, use aspect-ratio containers:
.video-embed {
aspect-ratio: 16 / 9;
width: 100%;
}
3. Handle web fonts properly
When a web font loads, text often reflows. The browser might show invisible text (FOIT) or swap from a fallback font (FOUT), both causing shifts.
What to do:
Use font-display: optional for non-critical fonts:
@font-face {
font-family: 'Custom Font';
src: url('/font.woff2') format('woff2');
font-display: optional;
}
For critical fonts, preload them:
<link
rel="preload"
href="/font.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Or use a font with similar metrics to your fallback to minimize the shift.
In Next.js, next/font can generate fallback metrics automatically. In Nuxt or a custom setup, keep font loading boring: preload only the critical face, avoid loading every weight in the hero, and test the page once with cache disabled.
4. Don't insert content above existing content
If you load a banner, notification, or any content that pushes existing content down, that's a layout shift.
What to do:
- Reserve space for dynamic content upfront
- Add new content below the viewport, not above
- Use transforms for animations instead of changing layout properties
/* Bad - causes layout shift */
.notification {
margin-top: 20px; /* Pushes content down */
}
/* Good - no layout shift */
.notification {
transform: translateY(20px);
}
If a banner must appear above the content, reserve the banner height from the first paint:
.top-banner-region {
min-height: 56px;
}
.top-banner-region:empty {
visibility: hidden;
}
That way the page does not reflow when the banner text or personalization data arrives.
Framework-specific CLS traps
| Stack | Common trap | Safer pattern |
|---|---|---|
| Next.js | Replacing a skeleton with content of a different height. | Match skeleton dimensions to the final card/list layout. |
| Nuxt | Rendering client-only widgets above the main article. | Reserve a stable container or move the widget below primary content. |
| Astro | Hydrated islands expanding after JavaScript loads. | Give the island wrapper the final dimensions in server-rendered HTML. |
| WordPress | Plugin banners and embeds injected above content. | Move injections below content or reserve fixed regions in the theme. |
| React SPAs | Route transitions that mount content with no height. | Keep route containers stable and animate opacity/transform instead of layout. |
The pattern is the same in every framework: the first HTML paint needs to include the space that later content will occupy.
Quick checklist
- Do all images have
widthandheight(oraspect-ratio)? - Do ads/embeds have reserved space?
- Are fonts preloaded or using
font-display: optional? - Is dynamic content inserted below the viewport?
- Are animations using
transforminstead of layout properties? - Do skeleton loaders match the final content height?
- Do cookie/consent banners reserve space before they appear?
- Are client-only widgets kept out of the top of the page?
- Have you tested with cache disabled and a mobile viewport?
What to compare after a CLS fix
Run the page twice: once with a cold cache and once with a warm cache. Font and image shifts often show up only on the cold run. Widget and ad shifts can show up later on the warm run because the primary page loads quickly and third-party content arrives afterward.
For each run, save:
- The Lighthouse/PageSpeed CLS value.
- A DevTools performance recording with Layout Shift markers visible.
- A screenshot or note of the shifted node from the PerformanceObserver snippet.
- The source diff that reserves the missing space.
If the score improves but the page still visibly jumps, keep debugging. Users experience the jump, not the lab score.
Frequently Asked Questions
What is a good CLS score?
A good CLS score is under 0.1. Scores between 0.1-0.25 need improvement, and anything over 0.25 is considered poor. Unlike other metrics, CLS is unitless - it's a calculation of how much content shifted and how far.
What causes Cumulative Layout Shift?
The most common causes are: images without dimensions, late-loading ads or embeds, web fonts that reflow text, and dynamic content inserted above existing content. Each of these causes visible elements to move unexpectedly.
Does CLS affect SEO?
Yes. CLS is one of Google's three Core Web Vitals and affects search rankings. Beyond SEO, poor CLS frustrates users - especially when they try to click something and it moves.
How do I fix CLS from images?
Always set width and height attributes on images, or use CSS aspect-ratio. This lets the browser reserve space before the image loads. Framework image components like Next.js <Image> and Nuxt <NuxtImg> handle this automatically.
How do I fix CLS from fonts?
Use font-display: optional or font-display: swap in your font-face declarations. Better yet, preload critical fonts or use a tool like Fontaine that creates fallback fonts with matching metrics.
Related guides
- Next.js Core Web Vitals - CLS fixes specific to Next.js including font and image handling
- Nuxt Performance Optimization - NuxtImg and layout stability patterns for Nuxt
- Astro Image Optimization - How Astro's Image component prevents CLS automatically
- Material UI Performance - CLS gotchas with MUI skeleton loaders and dynamic components
- React Performance Optimization - Avoid CLS from lazy-loaded React components
What's next
Want to see exactly which elements are causing your layout shifts? Run your site through PageSpeedFix for a prioritized breakdown with framework-specific fixes.