Fix Ads, Embeds & iframes for Better CLS
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.
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:
rootCauseInjectedIframewith iframe URL
Why This Destroys CLS
Third-party content has three problems:
- Unknown dimensions — Ad networks serve different creative sizes. YouTube embeds default to 0x0 until their script runs. Twitter cards have variable heights.
- Asynchronous loading — These scripts load after your content renders. By design, they arrive late.
- 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
| Strategy | Details | Impact |
|---|---|---|
| Mobile-First Reality | Google indexes the mobile version of your site. On a phone screen, a 300px height shift pushes all content off-screen, creating a much higher Impact Fraction than on desktop. | Critical |
| Field Data (CrUX) Truth | You cannot hide CLS from Google by blocking ad scripts in robots.txt. Ranking uses Chrome User Experience Report (CrUX) data from real users who do see the ads. | High |
| Accessibility & A11y | 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. | Medium |
How to Diagnose
DevTools Performance Panel
- Open DevTools > Performance tab
- Check "Web Vitals" checkbox
- Record and reload
- Click on Layout Shift entries
- 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:
| Format | Dimensions |
|---|---|
| Medium Rectangle | 300x250 |
| Leaderboard | 728x90 |
| Mobile Banner | 320x50 |
| Wide Skyscraper | 160x600 |
| Billboard | 970x250 |
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/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>
)
}
@next/third-parties:import { YouTubeEmbed } from '@next/third-parties/google'
<YouTubeEmbed videoid="dQw4w9WgXcQ" />
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>
nuxt-youtube or lite-youtube-embed.Verification
After implementing:
- Throttle network to Slow 3G in DevTools
- Watch the page load — container should appear with placeholder, then ad loads inside without shift
- Check Layout Shift Regions — no flashes around embed areas
- Run Lighthouse — CLS should improve significantly
- 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-heightnotheightso 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 — Same reserved space principle
- Dynamic Content — 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.
Related
Dynamic Content
Stop cookie banners, notifications, and lazy-loaded content from causing layout shifts with space reservation and transforms.
Animations & Transitions
Eliminate CLS from animations by using compositor-friendly properties like transform and opacity instead of layout-triggering properties.