Dynamic content is the silent CLS killer. 73% of pages with poor CLS scores have at least one element injected above existing content after initial render. Banners, notifications, cookie consent—they all push your content down.
Dynamic content injection means adding elements to the DOM after the page has rendered. When you inject content above existing content, everything below shifts down. The browser reports this as a layout shift.
The most common offenders:
The math is brutal. A 100px banner on a 800px viewport causes a shift score of ~0.125. Add a notification toast and a late-loading promo? You've already exceeded Google's 0.1 threshold before the user even scrolls.
Why it matters: Users start reading your content. Then everything jumps. They lose their place. On mobile, they accidentally tap the wrong link. Frustration compounds with each shift. Google penalizes pages with CLS above 0.1 in search rankings.
Performance Panel:
Layout Shift Regions:
Elements Panel:
Monitor DOM mutations while loading:
<body> > Break on > Subtree modificationsTrack shifts in real-time with their sources:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput)
continue // Ignore user-triggered shifts
console.group(`Shift: ${entry.value.toFixed(4)}`)
console.log('Time:', entry.startTime.toFixed(0), 'ms')
entry.sources?.forEach((source) => {
const el = source.node
console.log('Element:', el?.tagName, el?.className)
console.log('Moved from:', source.previousRect)
console.log('Moved to:', source.currentRect)
})
console.groupEnd()
}
}).observe({ type: 'layout-shift', buffered: true })
Look for shifts where the moved element is NOT the injected content but what came after it. That's your culprit.
If you know content will appear, reserve its space from the start.
Cookie banners with fixed height:
/* Reserve space in HTML before the banner loads */
.cookie-banner-placeholder {
min-height: 80px;
contain: layout;
}
.cookie-banner {
min-height: 80px;
}
<div class="cookie-banner-placeholder">
<!-- Banner script injects here -->
</div>
<main>
<!-- Page content stays put -->
</main>
Notification bars:
.notification-slot {
min-height: 48px;
contain: layout size;
}
/* Hide when empty */
.notification-slot:empty {
min-height: 0;
transition: min-height 0.2s ease-out;
}
The contain: layout size property tells the browser this element won't affect layout outside its bounds.
Transforms don't trigger layout recalculation. Slide content in rather than inserting it.
Slide-down banner:
.promo-banner {
transform: translateY(-100%);
transition: transform 0.3s ease-out;
position: relative;
z-index: 100;
}
.promo-banner.visible {
transform: translateY(0);
}
// Show banner without causing layout shift
const banner = document.querySelector('.promo-banner')
banner.classList.add('visible')
Toast notifications:
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
}
.toast {
transform: translateX(120%);
opacity: 0;
transition: transform 0.3s ease-out, opacity 0.2s;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
Fixed/absolute positioned elements don't cause layout shifts because they're outside normal document flow.
Elements with position: fixed or position: sticky don't cause CLS when they appear. The key is setting the position BEFORE the element renders, not after.
Cookie banner that never shifts:
.cookie-consent {
position: fixed;
bottom: 0;
left: 0;
right: 0;
transform: translateY(100%);
transition: transform 0.3s ease-out;
}
.cookie-consent.show {
transform: translateY(0);
}
Sticky header done right:
/* Wrong: starts static, becomes sticky */
.header {
position: relative;
}
.header.scrolled {
position: sticky; /* This causes shift */
}
/* Right: always sticky */
.header {
position: sticky;
top: 0;
}
Late-appearing sticky nav:
.sticky-nav {
position: sticky;
top: 60px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.sticky-nav.visible {
opacity: 1;
pointer-events: auto;
}
For async content that must be in document flow, show a skeleton with matching dimensions:
<div class="user-profile" data-user-id="123">
<!-- Skeleton shown immediately -->
<div class="skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text short"></div>
</div>
</div>
.user-profile {
min-height: 120px; /* Match actual content height */
contain: layout;
}
.skeleton {
display: flex;
gap: 16px;
padding: 16px;
}
.skeleton-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--ui-bg-muted);
}
.skeleton-text {
height: 16px;
background: var(--ui-bg-muted);
border-radius: 4px;
width: 200px;
}
.skeleton-text.short {
width: 100px;
}
// Replace skeleton with real content—same container size
async function loadProfile(container) {
const data = await fetch(`/api/user/${container.dataset.userId}`)
container.innerHTML = renderProfile(data)
// No shift because container height matches
}
Layout shifts occur when the browser recalculates element positions. Three strategies avoid this:
The common thread: tell the browser exactly where things will be BEFORE they appear.
import { Suspense } from 'react'
function Page() {
return (
<>
<CookieBanner />
{' '}
{/* Fixed position, no shift */}
<Suspense fallback={<NotificationSkeleton />}>
<NotificationBar />
</Suspense>
<main>
<Suspense fallback={<div style={{ minHeight: 400 }} />}>
<AsyncContent />
</Suspense>
</main>
</>
)
}
// Cookie banner with transform animation
function CookieBanner() {
const [show, setShow] = useState(true)
return (
<div
className={`fixed bottom-0 inset-x-0 transform transition-transform ${
show ? 'translate-y-0' : 'translate-y-full'
}`}
>
<div className="bg-white p-4 shadow-lg">
<p>We use cookies...</p>
<button onClick={() => setShow(false)}>Accept</button>
</div>
</div>
)
}
import { createPortal } from 'react-dom'
function Toast({ message }) {
return createPortal(
<div className="fixed top-4 right-4 animate-slide-in">
{message}
</div>,
document.body
)
}
ClientOnly with sized fallbacks:<script setup>
// Cookie banner uses transform, not layout
const showBanner = ref(true)
</script>
<template>
<div>
<!-- Fixed position cookie banner -->
<CookieBanner class="fixed bottom-0 inset-x-0" />
<!-- Async content with reserved space -->
<ClientOnly>
<PromoBar />
<template #fallback>
<div class="h-12 bg-gray-100" />
</template>
</ClientOnly>
<main>
<ClientOnly>
<UserDashboard />
<template #fallback>
<DashboardSkeleton />
</template>
</ClientOnly>
</main>
</div>
</template>
<script setup>
const toast = useToast()
// Toasts appear in fixed container—no CLS
toast.add({ title: 'Success!' })
</script>
1. Before/After Lighthouse
Run Lighthouse before and after. The "Avoid large layout shifts" audit should show fewer shifts from injected elements.
2. Performance Observer Check
let injectionShifts = 0
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput && entry.startTime > 500) {
// Shifts after 500ms are likely from dynamic content
injectionShifts++
console.log('Late shift detected:', entry.value)
}
}
}).observe({ type: 'layout-shift', buffered: true })
// After page load
setTimeout(() => {
console.log('Dynamic content shifts:', injectionShifts)
}, 5000)
3. Visual Test
Record screen while loading on Slow 3G. Play at 0.5x speed. Banners and notifications should slide/fade in, not push content.
Expected improvement: Pages with cookie banners and notifications typically drop from 0.2-0.3 CLS to under 0.05 after fixes.
Reserving wrong dimensions
/* Wrong: too small */
.banner-slot { min-height: 40px; }
/* Right: match actual banner */
.banner-slot { min-height: 80px; }
Measure your actual banner height including padding and margins.
Animating height instead of transform
/* Wrong: animates layout property */
.banner {
height: 0;
transition: height 0.3s;
}
.banner.show { height: 80px; }
/* Right: animates transform */
.banner {
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s;
}
.banner.show { transform: scaleY(1); }
Starting sticky too late
// Wrong: makes sticky after scroll
window.addEventListener('scroll', () => {
if (window.scrollY > 100) {
header.style.position = 'sticky' // Causes shift
}
})
// Right: always sticky, just change appearance
window.addEventListener('scroll', () => {
header.classList.toggle('compact', window.scrollY > 100)
})
Lazy loading without placeholders
<!-- Wrong -->
<div class="lazy-section" data-load-on-scroll>
<!-- Content loads, pushes everything down -->
</div>
<!-- Right -->
<div class="lazy-section" data-load-on-scroll style="min-height: 400px;">
<div class="skeleton">...</div>
</div>
Different pages have different dynamic content patterns. Your homepage might have a promo banner. Product pages might have stock notifications. Blog posts might have newsletter popups. Unlighthouse scans every page and identifies which dynamic elements are causing CLS problems across your entire site.