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 typeWhen it happensCommon causeFirst place to look
Load-time CLSBefore the user interactsImages, fonts, ads, banners, embedsLighthouse trace and DevTools Performance panel.
Post-load CLSAfter the page appears idleLate widgets, route hydration, delayed personalizationReal user monitoring or a manual interaction recording.
Interaction-adjacent shiftsNear clicks/tapsExpanding UI without reserved space, delayed state updatesComponent 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

  1. Open Chrome DevTools
  2. Go to Performance tab
  3. Enable "Web Vitals" in settings
  4. Record a page load
  5. 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

StackCommon trapSafer pattern
Next.jsReplacing a skeleton with content of a different height.Match skeleton dimensions to the final card/list layout.
NuxtRendering client-only widgets above the main article.Reserve a stable container or move the widget below primary content.
AstroHydrated islands expanding after JavaScript loads.Give the island wrapper the final dimensions in server-rendered HTML.
WordPressPlugin banners and embeds injected above content.Move injections below content or reserve fixed regions in the theme.
React SPAsRoute 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 width and height (or aspect-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 transform instead 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:

  1. The Lighthouse/PageSpeed CLS value.
  2. A DevTools performance recording with Layout Shift markers visible.
  3. A screenshot or note of the shifted node from the PerformanceObserver snippet.
  4. 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.

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.

D
Declan

Software engineer in Melbourne. Writes guides at PageSpeedFix and builds web performance tools. More at dekk.dev.