---
title: "Fix Layout Shifts: CSS Reflow, Font-Size & CLS Causes"
description: "Diagnose and fix layout shifts caused by CSS reflow, font-size changes, unsized media, web fonts, and injected iframes. Reduce your CLS score."
canonical_url: "https://unlighthouse.dev/learn-lighthouse/cls/layout-shifts"
last_updated: "2025-01-18"
---

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

<audit-impact current-value="0.32" metric="cls" target-value="0.1">



</audit-impact>

## 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.

Beyond SEO, layout shifts are a significant **accessibility barrier**. For users with cognitive disabilities, unpredictable movement can be disorienting. For those with motor impairments, it leads to "moving target" buttons that are impossible to click accurately, violating WCAG principles of being **Operable** and **Understandable**.

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:**

```js
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:

```html
<!-- 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:

```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**

```html
<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**

```css
@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**

```css
@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:**

```jsx
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:**

```html
<div class="embed-container" style="aspect-ratio: 16/9; min-height: 315px;">
  <!-- Third-party script will inject iframe here -->
</div>
```

```css
.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:**

```html
<div class="youtube-facade" data-video-id="xyz">
  <img src="/thumbnails/xyz.jpg" alt="Video thumbnail">
  <button>Play</button>
</div>
```

```js
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

<callout icon="i-logos-nextjs-icon">

**Next.js**

Handle all three root causes:

```jsx
// Fonts - next/font eliminates shift
import { Inter } from 'next/font/google'

// Images - next/image handles sizing
import Image from 'next/image'

const inter = Inter({ subsets: ['latin'] })

export function Demo() {
  return (
    <>
      <Image src="/hero.jpg" width={1200} height={630} alt="Hero" />
      {/* 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:

```jsx
import { Suspense } from 'react'

export function Content() {
  return (
    <Suspense fallback={<div style={{ height: 400 }} />}>
      <DynamicContent />
    </Suspense>
  )
}
```

</callout>

<callout icon="i-logos-nuxt-icon">

**Nuxt**

```html
<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`:

```ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        {
          rel: 'preload',
          href: '/fonts/main.woff2',
          as: 'font',
          type: 'font/woff2',
          crossorigin: 'anonymous'
        }
      ]
    }
  }
})
```

</callout>

## 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**

```js
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**

```js
// 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**

```html
<!-- 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.

## Related issues

Layout shifts have cascading causes:

- [Unsized Images](/learn-lighthouse/cls/unsized-images) - Deep dive on image dimensions
- [Web Fonts](/learn-lighthouse/cls/web-fonts-causing-foit) - Font loading strategies
- [Ads & Embeds](/learn-lighthouse/cls/ads-embeds-iframes) - Third-party content
- [Dynamic Content](/learn-lighthouse/cls/dynamic-content-injection) - JavaScript-injected elements
- [Animations](/learn-lighthouse/cls/animations-transitions) - Motion that causes reflow

## 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.
