Fix Ads, Embeds & iframes for Better CLS

Prevent layout shifts from third-party ads, video embeds, social widgets, and iframes with reserved space techniques.
Harlan WiltonHarlan Wilton6 min read Published

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.

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

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.

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

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:

FormatDimensions
Medium Rectangle300x250
Leaderboard728x90
Mobile Banner320x50
Wide Skyscraper160x600
Billboard970x250

Reserve the space before the ad loads:

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

Better approach with CSS containment:

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

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

.video-embed iframe {
  width: 100%;
  height: 100%;
  border: 0;
}
<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 entirely and improves page load:

<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>
.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);
}
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:

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

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

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

// 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

Next.jsUse next/script with lazyOnload and always wrap in sized container:
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:
import { YouTubeEmbed } from '@next/third-parties/google'

<YouTubeEmbed videoid="dQw4w9WgXcQ" />
NuxtUse useScript with idle trigger:
<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.

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

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.