Skip to content
TECHNICAL SEO / FIELD NOTE 044

Cumulative Layout Shift: Hidden Causes and Surgical Fixes

Reading map: The Session Window Algorithm; Measuring and Attributing CLS; Images and Iframes Without Dimensions; Web Fonts and FOUT/FOIT
A reading map of this field note. Download SVG ↓

Cumulative Layout Shift (CLS) is the Core Web Vital that causes the most confusion in practice — not because the metric is complex, but because its causes are. A developer looking at clean HTML and no obvious content injection can still find CLS of 0.3 in field data, driven by a combination of web fonts, ad slots, image dimensions, and CSS transitions that each contribute a small shift during a session window. This article dissects every significant CLS source, explains exactly how Chrome's session window algorithm aggregates them, and provides precise fixes with measurable outcomes.

The Session Window Algorithm

Understanding how CLS is calculated prevents a lot of wasted debugging effort. Chrome does not simply sum all layout shift scores across the page session. Since June 2021, it uses a session window model:

  1. Each layout shift event is assigned to a session window.
  2. A session window ends when there is a gap of more than 1 second between shifts, or when the window exceeds 5 seconds in total.
  3. The CLS score for the page is the maximum session window sum.

This model prevents long-lived SPAs from accumulating infinite CLS over a multi-minute session, but it also means a concentrated burst of shifts in a 2-second window is more damaging than the same shifts spread over 10 seconds. A page that shows an ad slot, then a cookie banner, then adjusts a sticky header — all within 3 seconds of load — will create one large session window rather than three separate ones.

Layout Shift Score Formula

Each individual shift entry score = impact fraction × distance fraction.

  • Impact fraction: the combined area of all shifting elements, as a fraction of the viewport. An element covering the full width and 50% of viewport height = 0.5 impact fraction.
  • Distance fraction: the maximum distance any element moved, divided by the viewport's largest dimension. An element that shifts 100px on a 800px viewport = 0.125 distance fraction.
  • Score = 0.5 × 0.125 = 0.0625 for that shift.

Shifts caused by user interaction (within 500 ms of a pointer/keyboard event) are excluded. This is why accordion opens and tab switches triggered by clicks do not affect CLS — provided they are genuinely user-initiated and not scripted delays.

Measuring and Attributing CLS

import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  const {
    largestShiftTarget,      // CSS selector of largest shift source
    largestShiftTime,        // When the shift occurred (ms after navigation)
    largestShiftValue,       // Score of the largest individual shift
    largestShiftSource,      // LayoutShiftAttribution object
    largestShiftEntry,       // Full LayoutShift entry
    loadState,               // 'loading' | 'dom-interactive' | 'dom-content-loaded' | 'complete'
  } = attribution;

  analytics.track('CLS', {
    value,
    rating: value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor',
    largestShiftTarget,
    largestShiftTime,
    loadState,
    // Log all shift sources for debugging
    sources: attribution.largestShiftEntry?.sources?.map(s => ({
      node: s.node?.nodeName,
      id: s.node?.id,
      className: s.node?.className,
      previousRect: s.previousRect,
      currentRect: s.currentRect,
    })),
  });
}, { reportAllChanges: true });

The loadState field is invaluable for triage: shifts during 'loading' typically come from unsized media or late-injected content; shifts during 'complete' often come from lazy-loaded iframes, chat widgets, or analytics-injected banners.

Images and Iframes Without Dimensions

This is the most common and most fixable CLS cause. When an image loads without explicit width and height attributes, the browser allocates zero space for it in layout. When the image loads and its intrinsic dimensions become known, the browser inserts the image and shifts everything below it downward.

The Fix: width + height Attributes

<!-- Wrong: browser allocates 0×0, shifts on load -->
<img src="/product.avif" alt="Widget">

<!-- Correct: browser reserves the exact space before image loads -->
<img src="/product.avif"
     width="800"
     height="600"
     alt="Widget"
     loading="lazy">

The width and height attributes must reflect the image's intrinsic dimensions (not the rendered size). The browser calculates the aspect ratio from these values and reserves the correct space even when CSS scales the image. Modern browsers use the aspect-ratio CSS property internally when both attributes are present — you do not need to set aspect-ratio manually.

Responsive Images and Aspect Ratio

<!-- CSS: ensure images fill their container while preserving aspect ratio -->
img {
  max-width: 100%;
  height: auto; /* Critical: allows height to scale with aspect ratio */
}

/* If using object-fit, set explicit height on the container, not the img */
.image-container {
  aspect-ratio: 4 / 3;
  overflow: hidden;
}

.image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Iframes

Iframes have the same problem. A YouTube embed without explicit dimensions will shift content when it loads. For embeds where the aspect ratio is known, use the padding-bottom trick or the modern aspect-ratio property:

<!-- Reserve space for 16:9 embed -->
<div style="aspect-ratio: 16/9; width: 100%;">
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    width="560"
    height="315"
    loading="lazy"
    title="Video title">
  </iframe>
</div>

Web Fonts and FOUT/FOIT

Web fonts cause CLS through Flash of Unstyled Text (FOUT): the browser renders text in a fallback system font, then swaps to the web font when it loads. If the web font has different metrics (line height, character width, ascender height) than the fallback, text reflows and shifts surrounding content.

font-display Values and Their CLS Impact

font-display Block Period Swap Period CLS Risk Recommendation
block 3 s Infinite High (late FOUT) Avoid for body text
swap 0 ms Infinite High (immediate FOUT) Use only with size-adjust
fallback 100 ms 3 s Medium Good default
optional 100 ms 0 ms None Best for CLS, worst for brand

size-adjust: Eliminating Font Swap CLS

size-adjust is a CSS @font-face descriptor that scales the fallback font to match the web font's metrics, making the swap invisible to the layout engine. Combined with ascent-override, descent-override, and line-gap-override, you can create a zero-CLS font loading experience even with font-display: swap:

/* Step 1: Define the web font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
  font-weight: 400;
}

/* Step 2: Create an adjusted fallback that matches Inter's metrics */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  /* These values are calculated to match Inter's metrics exactly */
  /* Use https://screenspan.net/fallback or fontaine/capsize tools */
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* Step 3: Use the fallback in font stack */
body {
  font-family: 'Inter', 'Inter-fallback', Arial, sans-serif;
}

Tools like Fontaine (a Nuxt/Next.js plugin) automate metric calculation and generate these overrides automatically. This approach eliminates font-swap CLS entirely on Google Fonts and self-hosted fonts.

Dynamic Content: Ads, Embeds, Banners

Dynamic content injected above existing content is the second most common CLS source in e-commerce and media sites. Ad slots, cookie consent banners, newsletter subscription bars, and live chat launchers are frequent offenders.

Ad Slot Strategy

The fix for ad slots is to reserve the exact space before the ad loads. GPT (Google Publisher Tag) ads have known maximum sizes. Reserve the largest size the ad unit can use:

<!-- Reserve minimum ad space — prevents shift when ad injects -->
<div class="ad-slot" style="min-height: 250px; width: 300px;">
  <!-- GPT ad unit renders here -->
  <div id="div-gpt-ad-1234567890"></div>
</div>

<style>
.ad-slot {
  /* Use aspect-ratio for responsive ad units */
  min-height: 250px;
  background: #f5f5f5; /* Visible placeholder prevents layout surprise */
  container-type: inline-size;
}
</style>

Cookie Consent Banners

The most common implementation mistake: injecting the banner into the DOM at the top of the page after the initial HTML has rendered. The correct approach is to include a reserved slot in the HTML that the banner populates, or to position the banner as position: fixed so it overlays rather than shifts content:

/* Fixed positioning = no layout shift */
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 999;
  /* Does NOT push content — overlays it */
}

/* Sticky header approach — also no shift */
.site-notification-bar {
  position: sticky;
  top: 0;
  z-index: 100;
}

CSS Animations and Transitions That Cause CLS

CSS transitions that animate properties causing layout (top, left, margin, padding, width, height) will cause CLS if they are not triggered by user interaction. Only transform and opacity are compositor-only properties that don't trigger layout recalculation and thus don't cause CLS.

/* Wrong: margin-top animation triggers layout, causes CLS */
.notification-enter {
  animation: slide-down 0.3s ease;
}
@keyframes slide-down {
  from { margin-top: -100px; }
  to { margin-top: 0; }
}

/* Correct: transform animation is compositor-only, zero CLS */
.notification-enter {
  animation: slide-down 0.3s ease;
}
@keyframes slide-down {
  from { transform: translateY(-100px); }
  to { transform: translateY(0); }
}

SPA Navigation and Route Changes

In SPAs, route changes can cause CLS if the new route content loads asynchronously and the page layout shifts as content arrives. The pattern of showing a skeleton screen that doesn't match the actual content dimensions is a subtle but significant CLS source.

// Skeleton must exactly match content dimensions
// Instead of generic skeleton, use content-specific placeholders

function ProductSkeleton() {
  return (
    <div className="product-skeleton">
      {/* Skeleton dimensions must match actual ProductCard dimensions */}
      <div className="skeleton-image" style={{ aspectRatio: '4/3' }} />
      <div className="skeleton-title" style={{ height: '24px', marginTop: '12px' }} />
      <div className="skeleton-price" style={{ height: '20px', marginTop: '8px', width: '60%' }} />
    </div>
  );
}

Before / After CLS Data

Issue CLS Before CLS After Fix Applied
Images without dimensions 0.18 0.03 Added width/height to all img tags
FOUT from Google Fonts 0.09 0.00 size-adjust fallback + font-display: optional
GPT ad slot (300×250) 0.11 0.01 Reserved 250px min-height on slot
Cookie banner (top injection) 0.07 0.00 Changed to position: fixed bottom
margin-top slide-in animation 0.04 0.00 Replaced with transform: translateY
Combined (session window max) 0.31 (Poor) 0.04 (Good) All fixes applied

FAQ

Does CLS measurement include shifts from user-initiated actions?

No. Shifts occurring within 500 ms of a user interaction (click, tap, key press) are excluded from CLS. This covers accordion opens, tab switches, modal opens, and similar UX patterns. However, the 500 ms window is measured from the interaction, not from when the shift occurs. A shift that happens 600 ms after a click — perhaps due to a slow animation or API-triggered content — is included in CLS.

Why does my CLS look good in Lighthouse but poor in CrUX?

Lighthouse runs a single synthetic page load in a clean browser. It does not see: personalized content that varies by user, A/B test variants, chat widget initialization (often deferred past Lighthouse's recording window), cookie consent banners (often suppressed for bot user-agents), or ad slots (blocked by Lighthouse's network throttling). Real users experience all of these. Field CLS is almost always higher than lab CLS for content-heavy and ad-supported sites.

How does CLS interact with infinite scroll?

Infinite scroll content loading below the viewport does not cause CLS — shifts of elements outside the viewport don't contribute to the score. However, if the user scrolls and new content pushes existing content upward into the viewport, that shift counts. The correct pattern is to append new content below the current viewport anchor, never above it. Use the scroll-anchoring CSS property (overflow-anchor: auto is the default) to help browsers maintain scroll position.

Can a well-implemented skeleton screen actually make CLS worse?

Yes. If the skeleton renders elements with different dimensions than the actual content, the swap from skeleton to content causes a layout shift. This is especially common with text-based skeletons where the number of lines or font size differs from the actual content. Measure the exact dimensions of your actual content and match them precisely in the skeleton, or use content-size reservations rather than visual skeleton elements.

Does CSS Grid or Flexbox usage affect CLS?

Not inherently. CLS is caused by elements moving, not by the layout algorithm used. However, auto-sizing grid/flex items can cause unexpected shifts when late-loading content changes sibling element sizes. Fix this by setting explicit min-height or min-width on grid/flex children that will receive dynamic content, preventing other items from moving when they fill.

How do I debug CLS in the browser without web-vitals.js?

// Native layout shift debugging
const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', {
        value: entry.value,
        time: entry.startTime,
        sources: entry.sources?.map(s => ({
          node: s.node,
          before: s.previousRect,
          after: s.currentRect
        }))
      });
    }
  });
});
observer.observe({ type: 'layout-shift', buffered: true });

Key Takeaways

  • CLS uses session windows (max 5 s, gap ≤ 1 s). The reported score is the maximum window sum, not the total. Concentrated bursts of shifts are maximally penalizing.
  • All <img> tags need explicit width and height attributes. The browser uses them to calculate aspect ratio and reserve space before image loads.
  • Font-swap CLS is eliminated by using size-adjust, ascent-override, and descent-override on the fallback font declaration to match the web font's metrics.
  • Ad slots require reserved minimum dimensions. Cookie banners should use position: fixed, not DOM insertion above content.
  • Only transform and opacity animations are compositor-only. Animating top, margin, height, or width causes layout recalculation and CLS.
  • User-interaction-triggered shifts within 500 ms are excluded from CLS. Design UX patterns to be causally linked to user input.
  • CrUX CLS is almost always higher than Lighthouse CLS on ad-supported or personalized sites. Always instrument field measurement with web-vitals.js.

Conclusion

CLS is a metric where the gap between "all the obvious fixes are applied" and "actually Good in CrUX" can be surprisingly large. The hidden causes — font metrics, ad timing, CSS animation properties, skeleton screen dimensions — each contribute small scores that compound in the session window into a Poor result. The diagnostic approach is to instrument field attribution, identify which elements are shifting and when, and apply targeted fixes in order of impact. Pair CLS fixes with an LCP optimization sprint and you address the two most common CWV failures simultaneously — most sites see the biggest CWV gains from these two metrics combined.

YOUR READING CHECKLIST

Make the ideas stick.

Mark the sections you’ve worked through. Saved in this browser.

0 of 4 reviewed
Andrii Stanetskyi
ABOUT THE AUTHOR

Andrii Stanetskyi

Head of SEO / Technical SEO Lead based in Tallinn, Estonia. Technical architecture, enterprise eCommerce, Python automation, and AI-assisted workflows.

More about Andrii ↗
LET’S FIND THE REAL BOTTLENECK

A clearer picture.
A practical next step.

Get a focused SEO audit or a consultation on your next technical decision. We’ll agree on the scope and fee before any work begins.

01 / Diagnose02 / Prioritize03 / Plan
How can I help?

Scope and fee agreed before any work begins.

Choose your language

Explore SEO services in 26 languages. Journal articles retain their original language.

ENEnglish↗DEDeutsch↗FRFrançais↗ESEspañol↗ITItaliano↗PTPortuguês↗NLNederlands↗PLPolski↗SVSvenska↗DADansk↗FISuomi↗NONorsk↗ETEesti↗LVLatviešu↗LTLietuvių↗CSČeština↗RORomână↗HUMagyar↗ELΕλληνικά↗BGБългарски↗HRHrvatski↗SKSlovenčina↗SLSlovenščina↗RUРусский↗UKУкраїнська↗TRTürkçe↗
LET’S WORK ON YOUR WEBSITE
A CLEAR NEXT STEP

Let’s talk
about your site.

A focused SEO audit or a conversation about a specific challenge. Tell me where you are and what you want to change.

Andrii Stanetskyi
Andrii StanetskyiHead of SEO / Technical SEO Lead
[email protected] ↗
How can I help?

Scope and fee agreed before any work begins.