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.
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:
rootCauseInjectedIframe with iframe URLThird-party content has three problems:
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.
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.
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 })
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.
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>
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)
})
})
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
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;
}
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.
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.After implementing:
PerformanceObserver — iframe-related shifts should be goneExpected improvement: 0.1-0.3 CLS reduction per properly reserved embed.
min-height not height so content can expandloading="lazy" or facade patternsPush back on your ad provider:
Ad and embed placements vary across templates. Unlighthouse scans your entire site and identifies which pages have CLS from third-party content.
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.