---
title: "Fix Ads, Embeds & iframes for Better CLS"
description: "Prevent layout shifts from third-party ads, video embeds, social widgets, and iframes with reserved space techniques."
canonical_url: "https://unlighthouse.dev/learn-lighthouse/cls/ads-embeds-iframes"
last_updated: "2025-01-18"
---

Third-party content is the hardest CLS problem to solve. You control neither the dimensions nor the load timing. Ads are the worst offenders - they shift content an average of 0.15 CLS on affected pages.

<audit-impact current-value="0.25" metric="cls" target-value="0.05">



</audit-impact>

## What lighthouse detects

Lighthouse's layout-shifts audit specifically flags "Injected iframe" as a root cause. When an iframe loads asynchronously and pushes content, it gets flagged with its URL source.

The audit tracks:

- Layout shift score per event
- Impacted nodes (elements that moved)
- Root cause: `rootCauseInjectedIframe` with iframe URL

## Why this destroys CLS

Third-party content has three problems:

1. **Unknown dimensions**: Ad networks serve different creative sizes. YouTube embeds default to 0x0 until their script runs. Twitter cards have variable heights.
2. **Asynchronous loading**: These scripts load after your content renders. By design, they arrive late.
3. **Dynamic resizing**: Some ads resize based on viewport, ad availability, or user interaction.

**Real impact:** A single 300x250 ad loading in the middle of an article causes 0.1-0.3 CLS. If you have multiple ad placements, you can easily exceed 0.5 CLS.

## Strategic insights

<table>
<thead>
  <tr>
    <th align="left">
      Strategy
    </th>
    
    <th align="left">
      Details
    </th>
    
    <th align="left">
      Impact
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td align="left">
      <strong>
        Mobile-First Reality
      </strong>
    </td>
    
    <td align="left">
      Google indexes the mobile version of your site. On a phone screen, a 300px height shift pushes <em>
        all
      </em>
      
       content off-screen, creating a much higher Impact Fraction than on desktop.
    </td>
    
    <td align="left">
      <strong>
        Critical
      </strong>
    </td>
  </tr>
  
  <tr>
    <td align="left">
      <strong>
        Field Data (CrUX) Truth
      </strong>
    </td>
    
    <td align="left">
      You cannot hide CLS from Google by blocking ad scripts in <code>
        robots.txt
      </code>
      
      . Ranking uses <a href="/learn-lighthouse/core-web-vitals">
        Chrome User Experience Report (CrUX)
      </a>
      
       data from real users who <em>
        do
      </em>
      
       see the ads.
    </td>
    
    <td align="left">
      <strong>
        High
      </strong>
    </td>
  </tr>
  
  <tr>
    <td align="left">
      <strong>
        Accessibility & A11y
      </strong>
    </td>
    
    <td align="left">
      Layout shifts aren't just visual annoyances. For users with motor impairments or those using screen magnifiers, an ad snapping into place can cause them to lose their reading position entirely.
    </td>
    
    <td align="left">
      <strong>
        Medium
      </strong>
    </td>
  </tr>
</tbody>
</table>

## How to diagnose

### DevTools performance panel

1. Open DevTools > Performance tab
2. Check "Web Vitals" checkbox
3. Record and reload
4. Click on Layout Shift entries
5. Look at "Sources" - if it shows an iframe or ad container, this guide is for you

### Layout shift regions

Enable "Layout Shift Regions" in DevTools Rendering tab. Reload the page slowly (throttle to Slow 3G). Blue/red flashes around iframe areas confirm the problem.

### Console snippet

```js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    entry.sources?.forEach((source) => {
      if (source.node?.tagName === 'IFRAME') {
        console.log('iframe shift:', entry.value, source.node.src)
      }
    })
  }
}).observe({ type: 'layout-shift', buffered: true })
```

## The fixes

### 1. reserve exact space for ads

Know your ad sizes. Common IAB standard sizes:

<table>
<thead>
  <tr>
    <th>
      Format
    </th>
    
    <th>
      Dimensions
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Medium Rectangle
    </td>
    
    <td>
      300x250
    </td>
  </tr>
  
  <tr>
    <td>
      Leaderboard
    </td>
    
    <td>
      728x90
    </td>
  </tr>
  
  <tr>
    <td>
      Mobile Banner
    </td>
    
    <td>
      320x50
    </td>
  </tr>
  
  <tr>
    <td>
      Wide Skyscraper
    </td>
    
    <td>
      160x600
    </td>
  </tr>
  
  <tr>
    <td>
      Billboard
    </td>
    
    <td>
      970x250
    </td>
  </tr>
</tbody>
</table>

Reserve the space before the ad loads:

```html
<div class="ad-container" style="min-height: 250px; min-width: 300px;">
  <!-- Ad script injects here -->
</div>
```

Better approach with CSS containment:

```css
.ad-slot {
  contain: layout size;
  min-height: 250px;
  min-width: 300px;
  background: var(--ui-bg-muted);
}

.ad-slot::before {
  content: 'Advertisement';
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--ui-text-dimmed);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.ad-slot:has(iframe)::before {
  display: none;
}
```

The `contain: layout size` tells the browser this element won't affect layout outside its bounds. Critical for preventing CLS.

### 2. use aspect-ratio for video embeds

YouTube, Vimeo, and most video platforms use 16:9. Reserve it:

```css
.video-embed {
  aspect-ratio: 16/9;
  width: 100%;
  contain: layout;
  background: #000;
}

.video-embed iframe {
  width: 100%;
  height: 100%;
  border: 0;
}
```

```html
<div class="video-embed">
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    loading="lazy"
    title="Video title"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen
  ></iframe>
</div>
```

### 3. facade pattern for embeds

Load a static preview, replace with real embed on interaction. Eliminates CLS and improves page load:

```html
<div class="youtube-facade" data-video-id="dQw4w9WgXcQ">
  <img
    src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
    alt="Video thumbnail"
    width="1280"
    height="720"
  >
  <button class="play-button" aria-label="Play video">
    <svg viewBox="0 0 68 48" width="68" height="48">
      <path fill="#f00" d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"/>
      <path fill="#fff" d="M45 24L27 14v20"/>
    </svg>
  </button>
</div>
```

```css
.youtube-facade {
  aspect-ratio: 16/9;
  width: 100%;
  position: relative;
  cursor: pointer;
  background: #000;
}

.youtube-facade img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.play-button {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: none;
  border: none;
  cursor: pointer;
  transition: transform 0.2s;
}

.play-button:hover {
  transform: translate(-50%, -50%) scale(1.1);
}
```

```js
document.querySelectorAll('.youtube-facade').forEach((facade) => {
  facade.addEventListener('click', () => {
    const videoId = facade.dataset.videoId
    const iframe = document.createElement('iframe')
    iframe.src = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1`
    iframe.allow = 'autoplay; encrypted-media; picture-in-picture'
    iframe.allowFullscreen = true
    iframe.style.cssText = 'width:100%;height:100%;border:0;position:absolute;top:0;left:0'
    facade.innerHTML = ''
    facade.appendChild(iframe)
  })
})
```

### 4. handle social media embeds

Twitter/X embeds are notoriously bad for CLS - they load, measure content, then resize. Reserve generous space:

```css
.twitter-embed {
  min-height: 400px; /* Twitter cards are tall */
  contain: layout;
}

.instagram-embed {
  aspect-ratio: 4/5;
  min-height: 500px;
  contain: layout;
}
```

Better: Use oEmbed APIs and render static previews:

```js
// Fetch embed data server-side, render static HTML
// Only load interactive widget on user request
```

### 5. sticky ads avoid CLS

Ads at screen edges with `position: fixed` or `position: sticky` don't cause layout shifts:

```css
.sticky-ad {
  position: sticky;
  top: 20px;
  height: 600px;
  width: 160px;
}

.fixed-bottom-ad {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50px;
  z-index: 1000;
}
```

### 6. handle ad collapse

Sometimes no ad fills the slot. Handle gracefully:

```js
// Example: Google Publisher Tag collapse callback
googletag.pubads().addEventListener('slotRenderEnded', (event) => {
  if (event.isEmpty) {
    const slot = document.getElementById(event.slot.getSlotElementId())
    slot.style.minHeight = '0'
    slot.style.transition = 'min-height 0.3s ease-out'
  }
})
```

The transition smooths the collapse. Still causes CLS, but reduces perceived jank.

## Framework solutions

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

**Next.js**

Use `next/script` with `lazyOnload` and always wrap in sized container:

```jsx
import Script from 'next/script'

export function AdSlot({ width = 300, height = 250 }) {
  return (
    <div
      style={{
        minWidth: width,
        minHeight: height,
        contain: 'layout size'
      }}
    >
      <Script
        src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"
        strategy="lazyOnload"
      />
      <div id="ad-slot-1" />
    </div>
  )
}
```

For YouTube, use `@next/third-parties`:

```jsx
import { YouTubeEmbed } from '@next/third-parties/google'

export const Video = () => <YouTubeEmbed videoid="dQw4w9WgXcQ" />
```

</callout>

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

**Nuxt**

Use `useScript` with idle trigger:

```html
<script setup>
const adLoaded = ref(false)

useScript('https://securepubads.g.doubleclick.net/tag/js/gpt.js', {
  trigger: 'idle',
  onLoad: () => { adLoaded.value = true }
})
</script>

<template>
  <div class="ad-container" :class="{ loaded: adLoaded }">
    <div id="ad-slot" />
  </div>
</template>

<style scoped>
.ad-container {
  min-height: 250px;
  min-width: 300px;
  contain: layout size;
}
</style>
```

For YouTube, use `nuxt-youtube` or lite-youtube-embed.

</callout>

## Verification

After implementing:

1. **Throttle network** to Slow 3G in DevTools
2. **Watch the page load**: container should appear with placeholder, then ad loads inside without shift
3. **Check Layout Shift Regions**: no flashes around embed areas
4. **Run Lighthouse**: CLS should improve significantly
5. **Use PerformanceObserver**: iframe-related shifts should be gone

**Expected improvement:** 0.1-0.3 CLS reduction per properly reserved embed.

## Common mistakes

- **Forgetting contain: layout**: Without it, ad content can still affect surrounding layout
- **Wrong size estimates**: Check your ad network's actual creative sizes
- **Not handling responsive**: Use `min-height` not `height` so content can expand
- **Loading all embeds eagerly**: Use `loading="lazy"` or facade patterns

## Working with ad networks

Push back on your ad provider:

- Request fixed-size ad units only
- Ask for collapse callbacks
- Consider fewer, larger placements over many small ones
- Use sticky/fixed positions where possible

## Related issues

- [Unsized Images](/learn-lighthouse/cls/unsized-images) - Same reserved space principle
- [Dynamic Content](/learn-lighthouse/cls/dynamic-content-injection) - Ads are dynamic content

## Site-wide audit

Ad and embed placements vary across templates. [Unlighthouse](/) scans your entire site and identifies which pages have CLS from third-party content.
