Fix Dynamic Content Injection for Better CLS

Stop cookie banners, notifications, and lazy-loaded content from causing layout shifts with space reservation and transforms.
Harlan WiltonHarlan Wilton7 min read Published

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.

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

What's the Problem?

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:

  • Cookie consent banners at the top of the page
  • Notification bars (sales, announcements, promotions)
  • Async-loaded components that push siblings down
  • Lazy-loaded content without reserved space
  • Sticky headers that appear after scroll
  • Chat widgets that resize their container

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.

How to Identify This Issue

Chrome DevTools

Performance Panel:

  1. Open DevTools > Performance
  2. Enable "Web Vitals" checkbox
  3. Record a page load
  4. Look for Layout Shift entries in the timeline
  5. Click each to see which element moved

Layout Shift Regions:

  1. Open DevTools > Rendering panel (three dots > More tools > Rendering)
  2. Enable "Layout Shift Regions"
  3. Reload the page
  4. Watch for blue/green flashes when banners or notifications appear

Elements Panel:

Monitor DOM mutations while loading:

  1. Open Elements panel
  2. Right-click <body> > Break on > Subtree modifications
  3. Reload and step through each DOM insertion

Performance Observer

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

The Fix

1. Reserve Space for Known Content

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.

2. Use Transforms Instead of Layout Changes

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.

3. Fixed/Sticky Positioning from Start

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;
}

4. Skeleton Screens

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
}

Why This Works

Layout shifts occur when the browser recalculates element positions. Three strategies avoid this:

  1. Reserved space — Browser allocates room upfront. When content fills it, nothing moves.
  2. Transforms — CSS transforms don't trigger layout. Elements visually move without affecting siblings.
  3. Fixed/sticky positioning — These elements exist in their own layer. They never participate in document flow.
  4. Skeletons — Matching dimensions means swapping content doesn't resize the container.

The common thread: tell the browser exactly where things will be BEFORE they appear.

Framework-Specific Solutions

Next.jsHandle dynamic content with Suspense boundaries:
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>
  )
}
For notifications, use a portal to fixed container:
import { createPortal } from 'react-dom'

function Toast({ message }) {
  return createPortal(
    <div className="fixed top-4 right-4 animate-slide-in">
      {message}
    </div>,
    document.body
  )
}
NuxtUse 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>
For notifications, use Nuxt UI's toast system which uses fixed positioning:
<script setup>
const toast = useToast()

// Toasts appear in fixed container—no CLS
toast.add({ title: 'Success!' })
</script>

Verify the Fix

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.

Common Mistakes

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>

Test Your Entire Site

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.