Fix Large Layout Shifts for Better CLS

Identify and eliminate the biggest layout shifts on your page. Diagnose root causes including unsized media, web fonts, and injected iframes.
Harlan WiltonHarlan Wilton6 min read Published

Lighthouse found significant layout shifts on your page. Each shift degrades user experience - content jumps, clicks miss their targets, users lose their place.

CLSCumulative Layout Shift CWV
25% weight
Good ≤0.10Poor >0.25

What's the Problem?

A layout shift occurs when a visible element changes position between two rendered frames. The browser calculates a shift score based on the impact fraction (how much of the viewport shifted) multiplied by the distance fraction (how far it moved).

The three main root causes Lighthouse detects:

  1. Unsized Media - Images, videos, or iframes without explicit dimensions
  2. Web Font Loading - Text reflows when custom fonts replace fallbacks
  3. Injected Iframes - Third-party iframes inserted dynamically

Each shift has a weighted score. Shifts during the largest "session window" (a burst of shifts within 5 seconds, with gaps under 1 second) contribute to your CLS metric. Lighthouse reports the top 15 shifts regardless of whether they're in the CLS calculation window.

Why it matters: Users interact with your page while it's loading. A 0.25 CLS means a quarter of your viewport unexpectedly moved. On mobile, that's enough to cause misclicks, frustration, and abandonment. Google uses CLS as a ranking factor. Pages scoring above 0.1 are penalized in search results.

The audit shows each shift's score and the element that moved the most. Sub-items reveal the probable cause - unsized image, font load, or iframe injection.

How to Identify This Issue

Chrome DevTools

Performance Panel Method:

  1. Open DevTools > Performance
  2. Enable "Web Vitals" checkbox
  3. Record a page load
  4. Look for red "LS" markers in the timeline
  5. Click each to see which element shifted and by how much

Live Monitoring:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput)
      continue // Ignore user-initiated shifts

    console.group(`Layout Shift: ${entry.value.toFixed(4)}`)
    console.log('Time:', entry.startTime.toFixed(0), 'ms')

    for (const source of entry.sources || []) {
      console.log('Element:', source.node)
      console.log('Previous rect:', source.previousRect)
      console.log('Current rect:', source.currentRect)
    }
    console.groupEnd()
  }
}).observe({ type: 'layout-shift', buffered: true })

This logs every shift with the exact element and its before/after positions.

Lighthouse

The "Avoid large layout shifts" audit lists:

  • Element: The DOM node that shifted the most in each shift event
  • Layout shift score: The weighted contribution to CLS
  • Root cause (sub-items): Why the shift happened

A page with 5 shifts of 0.05 each has the same CLS as one shift of 0.25 - but different debugging approaches. Multiple small shifts suggest systemic issues (many unsized images). One large shift points to a specific culprit (hero image, late-loading banner).

The Fix

1. Fix Unsized Media (Most Common)

When Lighthouse shows "Media element lacking an explicit size" as a root cause:

<!-- Before: causes shift -->
<img src="/hero.jpg" alt="Hero">
<video src="/promo.mp4"></video>
<iframe src="https://youtube.com/embed/xyz"></iframe>

<!-- After: no shift -->
<img src="/hero.jpg" width="1200" height="630" alt="Hero">
<video src="/promo.mp4" width="1920" height="1080"></video>
<iframe src="https://youtube.com/embed/xyz" width="560" height="315"></iframe>

For responsive layouts, combine with CSS:

img, video, iframe {
  max-width: 100%;
  height: auto;
}

/* Or use aspect-ratio */
.video-embed {
  aspect-ratio: 16 / 9;
  width: 100%;
}

The browser calculates the aspect ratio from width/height attributes and reserves space accordingly.

2. Fix Web Font Shifts

When Lighthouse shows "Web font loaded" as a root cause:

Option A: Preload critical fonts

<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>

This fetches the font early, reducing the time text displays with a fallback.

Option B: Use font-display: optional

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/main.woff2') format('woff2');
  font-display: optional;
}

The font only renders if it loads within ~100ms. Otherwise, the fallback persists - no shift.

Option C: Match fallback metrics

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/main.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%;
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

Adjust these values until your fallback font has the same line height and width as your custom font. No reflow = no shift.

Next.js with next/font:

import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  adjustFontFallback: true, // Auto-generates fallback metrics
})

3. Fix Injected Iframes

When Lighthouse shows "Injected iframe" as a root cause:

Reserve space before injection:

<div class="embed-container" style="aspect-ratio: 16/9; min-height: 315px;">
  <!-- Third-party script will inject iframe here -->
</div>
.embed-container {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: var(--ui-bg-muted);
}

.embed-container iframe {
  width: 100%;
  height: 100%;
}

Load iframes below the fold:

Iframes that inject below the visible viewport don't cause CLS. Move non-critical embeds (comments, social feeds) further down the page.

Use facade pattern:

<div class="youtube-facade" data-video-id="xyz">
  <img src="/thumbnails/xyz.jpg" alt="Video thumbnail">
  <button>Play</button>
</div>
document.querySelector('.youtube-facade').onclick = function () {
  const iframe = document.createElement('iframe')
  iframe.src = `https://youtube.com/embed/${this.dataset.videoId}?autoplay=1`
  iframe.width = this.offsetWidth
  iframe.height = this.offsetHeight
  this.replaceWith(iframe) // Swap, same size, no shift
}

The iframe loads only on interaction, after the user has stopped scrolling.

Why This Works

Layout shifts happen when the browser doesn't know element sizes upfront. Every fix follows the same principle: tell the browser the size before the content loads.

  • Explicit dimensions on media: browser reserves exact space
  • Font metrics matching: fallback and final have same line boxes
  • Container sizing for iframes: space reserved before injection

The browser does layout calculation once, not twice.

Framework-Specific Solutions

Next.jsHandle all three root causes:
// Images - next/image handles sizing
import Image from 'next/image'
<Image src="/hero.jpg" width={1200} height={630} alt="Hero" />

// Fonts - next/font eliminates shift
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })

// Iframes - wrap in sized container
<div className="aspect-video relative">
  <iframe src="..." className="absolute inset-0 w-full h-full" />
</div>
For dynamic content that might cause shifts:
import { Suspense } from 'react'

<Suspense fallback={<div style={{ height: 400 }} />}>
  <DynamicContent />
</Suspense>
Nuxt
<template>
  <!-- Images with NuxtImg -->
  <NuxtImg src="/hero.jpg" width="1200" height="630" alt="Hero" />

  <!-- Iframes with container -->
  <div class="aspect-video">
    <iframe src="..." class="w-full h-full" />
  </div>

  <!-- Client-only with sized fallback -->
  <ClientOnly>
    <ThirdPartyWidget />
    <template #fallback>
      <div class="h-[400px] bg-gray-100" />
    </template>
  </ClientOnly>
</template>
For fonts, configure in nuxt.config.ts:
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        {
          rel: 'preload',
          href: '/fonts/main.woff2',
          as: 'font',
          type: 'font/woff2',
          crossorigin: 'anonymous'
        }
      ]
    }
  }
})

Verify the Fix

1. Re-run Lighthouse

The "Avoid large layout shifts" audit should show fewer shifts with lower scores. Target: no shifts above 0.05, total CLS under 0.1.

2. Monitor live CLS

let clsValue = 0
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value
    }
  }
  console.log('Running CLS:', clsValue.toFixed(4))
}).observe({ type: 'layout-shift', buffered: true })

Reload multiple times. CLS should be consistently under 0.1.

3. Visual inspection

Record your screen while loading on throttled connection (DevTools > Network > Slow 3G). Play back at 0.5x speed. You shouldn't see content jumping.

Expected improvement: Fixing root causes typically reduces CLS by 50-90%. A page with CLS of 0.32 from unsized hero + font shift + ad iframe can reach 0.05 after fixes.

Common Mistakes

Fixing shifts outside the measurement window

Lighthouse shows shifts that may not be in the CLS window (due to windowing algorithm). Focus on the highest-score shifts first - they impact CLS most.

Ignoring small shifts

A shift of 0.02 seems minor. But 5 of them = 0.1 CLS. Fix all sources, not just the biggest.

Using JavaScript to set dimensions after load

// Wrong - dimension set after image loads, too late
img.onload = () => {
  img.width = img.naturalWidth
  img.height = img.naturalHeight
}

Dimensions must be in HTML or CSS, available during initial parse.

Assuming lazy loading prevents CLS

<!-- Still causes shift when image enters viewport -->
<img src="/photo.jpg" loading="lazy">

Lazy-loaded images still need dimensions. The shift happens when they load into view.

Layout shifts have cascading causes:

Test Your Entire Site

Different pages have different shift patterns. A product page might have unsized thumbnails. A blog post might have embedded videos. A landing page might have late-loading CTAs. Unlighthouse scans your entire site and reports CLS per page with the specific shifts causing problems.