Fix Cumulative Layout Shift (CLS) in 2026: Attribution, Session Windows, and Per-Source Fixes

Fix Cumulative Layout Shift fast: capture per-element attribution with web-vitals, replay shifts in Chrome DevTools, and ship one-line fixes for the five sources that cause 90% of real CLS.

Fix CLS in 2026: Attribution & Debugging

Updated: June 25, 2026

Cumulative Layout Shift (CLS) is fixed by reserving space for every element before it loads: set explicit width/height or aspect-ratio on media, use size-adjust on font fallbacks, and give ads and banners a fixed min-height. To find the offenders, capture per-element attribution in the field with the web-vitals library, then reproduce shifts in Chrome DevTools using the Layout Shift Regions overlay. Honestly, in my profiling work the same five sources cause more than 90% of CLS in production, and every one of them has a one-line fix once you know which element to blame.

  • CLS is the worst single shift inside a 5-second session window (gap ≤ 1s), not a sum of every shift on the page.
  • Shifts within 500ms of a user input carry the hadRecentInput flag and are excluded from the score.
  • The web-vitals v5 attribution build returns largestShiftSource down to the element selector. Paste it straight into your RUM payload.
  • Chrome DevTools' Performance panel now shows live LCP, CLS, and INP without recording; the Layout Shift Regions overlay paints every shift in blue.
  • Five sources cover 90%+ of real CLS: unsized images, late-loading fonts, ad slots, cookie banners, and layout-property animations.
  • Lab CLS only measures the initial load; field CLS keeps accumulating during scroll and interaction, which is why they disagree.

What is Cumulative Layout Shift in 2026?

Cumulative Layout Shift is the Core Web Vital that quantifies visual stability, specifically how much visible content moves unexpectedly between two rendered frames. Each individual shift is scored as impact fraction × distance fraction, meaning the share of the viewport that moved, multiplied by how far it moved relative to the viewport's largest dimension. A 50%-tall element shifting 20% of the viewport down produces a score of 0.5 × 0.2 = 0.10. The page-level CLS is then the worst session window of shifts (more on that below).

The thresholds set by the Chrome team's CLS documentation are unchanged in 2026: good is ≤ 0.10, needs improvement is ≤ 0.25, and poor is above that. To pass for SEO, the 75th-percentile CLS across real users (sourced from the Chrome User Experience Report, or CrUX) must be in the "good" band. CLS still has the highest pass rate of the three Core Web Vitals, mostly because the fixes are mechanical: aspect-ratio, size-adjust, and min-height. The score punishes layout-thrashing patterns that frustrate readers (text reflowing under your finger as you tap), so it correlates with rage-clicks and bounce in our session replays more than with raw load time.

Session windows: why CLS isn't just a sum

This is the part most teams get wrong. The page's CLS is not the sum of every layout shift that ever happens. It's the maximum sum within a sliding session window. A session window starts at the first shift, accepts further shifts that occur within 1 second of the previous one, and closes after either 5 seconds total or a 1-second gap with no shifts. Whichever session window ends up with the largest total is reported as the page's CLS for the rest of the lifecycle.

This matters because pages with one painful initial load shift plus dozens of tiny scroll-triggered shifts can still hit "good" if the tinies stay below 0.10 per window. Conversely, a page that injects an ad, then loads a font, then renders late comments (all within 5 seconds) accumulates a single ugly window even if each shift is small. I've seen e-commerce templates where moving a third-party chat widget out of the initial 5-second window dropped the 75th-percentile CLS from 0.18 to 0.07 with zero other changes. So yeah, the timing alone can be your fix.

The algorithm is implemented in the Layout Instability API via PerformanceObserver. Below is a minimal reproduction that prints each shift with its window so you can sanity-check what the browser is grouping together:

// Log every layout shift and the rolling session window it belongs to.
let sessionValue = 0;
let sessionEntries = [];
let clsValue = 0;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue; // user-initiated shifts are excluded

    const firstEntry = sessionEntries[0];
    const lastEntry = sessionEntries[sessionEntries.length - 1];

    // If the entry is < 1s after the previous and < 5s after the first, keep it in the same window.
    if (
      sessionEntries.length &&
      entry.startTime - lastEntry.startTime < 1000 &&
      entry.startTime - firstEntry.startTime < 5000
    ) {
      sessionValue += entry.value;
      sessionEntries.push(entry);
    } else {
      sessionValue = entry.value;
      sessionEntries = [entry];
    }

    if (sessionValue > clsValue) {
      clsValue = sessionValue;
      console.log('New worst CLS window:', clsValue, sessionEntries);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

If you want to skip the algorithm yourself, the production-grade version of the same logic lives in our RUM setup with the web-vitals library guide. It ships per-window attribution into Google Analytics 4 with no extra wiring.

What causes CLS?

After auditing several hundred sites over the past three years, I keep finding the same five root causes. Ranked by frequency in our profiling logs from Q1 2026:

  1. Images and iframes without explicit dimensions. The single biggest contributor. The browser cannot reserve space for an image whose intrinsic size it doesn't yet know, so when the bytes arrive the surrounding content jumps. Roughly 62% of mobile pages still ship at least one unsized image.
  2. Web fonts loading after first paint. Different fonts have different cap heights, x-heights, and per-character widths. When the web font finally swaps in, every paragraph reflows.
  3. Dynamically injected content above existing content. Cookie banners that push the page down, "as seen in" promo bars, A/B test variants that render late.
  4. Late-binding ad slots. Header bidding waterfalls that resolve after first paint, then expand a previously zero-height div to 250px tall.
  5. Animations on layout-triggering properties. Sliding a menu in with left: 0 instead of transform: translateX(0); expanding a card with height instead of transform: scaleY().

Other contributors you'll see less often: rotating display: none/block toggles on hover, native form controls re-rendering, hydration-time mismatches in React/Vue that re-paint differently from the server HTML, and viewport-unit calculations changing when the mobile address bar collapses. Each gets its own diagnosis pattern below.

How do you find what's causing CLS?

Attribution is the whole game. Knowing your CLS is 0.18 tells you nothing actionable. You need to know which element shifted, what caused it to shift (the shift target is often the victim, not the perpetrator), and when in the page lifecycle the shift happened. The 2026 toolkit has three layers: a lab tool (DevTools), a field library (web-vitals attribution build), and an external monitor (DebugBear, SpeedCurve, or your RUM of choice). Each catches a different bucket:

ToolLab / FieldPer-element attributionPost-load shiftsBest for
Chrome DevTools Performance panelLabYes (Moved from / Moved to)Yes, while recordingReproducing a specific shift you can see
web-vitals (attribution build)FieldYes (largestShiftSource)YesCatching real-user CLS regressions in RUM
Lighthouse / PageSpeed InsightsLab + CrUX fieldPartial (Avoid large layout shifts audit)No (lab); yes (field)Pre-deploy gates and SEO scoring
DebugBear / SpeedCurveFieldYes, with filmstripYesVisual debugging without writing instrumentation
CrUX Dashboard / Search ConsoleFieldNoYesTracking the 75th-percentile that Google sees

My usual order: start in RUM with the attribution build to find which URLs and templates regress, then reproduce one in DevTools to identify the structural cause, then write the fix and put a Lighthouse CI budget on it. Skipping the RUM step is the most common mistake. Fixing whatever shift you can reproduce locally rarely matches what 75% of users actually experience on mid-tier Android.

How do I debug CLS in Chrome DevTools?

Chrome DevTools rewrote the CLS workflow in late 2025, and it's now the fastest way to debug a reproducible shift. Three features carry the workflow.

1. Live Local Metrics (no recording needed)

Open the Performance panel and look at the Local Metrics section at the top. LCP, CLS, and INP update in real time, color-coded against the thresholds. You can interact with the page normally (scroll, click, hover) and watch CLS climb without starting a trace. This is, hands down, the single biggest workflow improvement of the year for performance work.

2. Layout Shift Regions overlay

Open Rendering (Command Menu → "Show Rendering") and tick Layout Shift Regions. Every shift paints a translucent blue rectangle on the exact region that moved, for a few hundred milliseconds. Resize the window, scroll a long article, or click a "Show more" button. Anything that triggers a shift will flash blue. This is how I locate scroll-triggered shifts that don't appear in a normal page load.

3. Performance trace with Moved from / Moved to

Hit Record, reload, scroll, then stop. Find the purple Layout Shift blocks on the Experience track. Click one and the Summary tab shows the Score, the Moved from rectangle (where the element was), and the Moved to rectangle (where it ended up). Hover those fields and DevTools highlights both positions on the page screenshot. The shift's Cumulative Score column tells you how much it contributed to the worst session window.

For trace-based debugging similar to how our Long Animation Frames API guide walks through INP, the Performance panel now exposes Gemini-powered "Insights" that summarize the worst shift and propose a fix. I treat these as a starting hypothesis, not a verdict; they're usually right about the where and wrong about the why.

Capturing CLS attribution in production

Lab debugging fails on the long tail. Some shifts only fire on specific viewport widths, specific A/B variants, or specific Android Chrome versions with WebView quirks. The only way to catch them is field instrumentation, and the modern answer is the web-vitals library's attribution build. Install it once and ship per-element CLS data into your analytics:

// Import from the attribution path (slightly larger bundle, much richer payload).
import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  // attribution.largestShiftTarget is a CSS selector for the worst-shifted element.
  // attribution.largestShiftEntry is the raw layout-shift PerformanceEntry.
  // attribution.largestShiftSource is the offending node descriptor.
  // attribution.loadState tells you if the shift happened during load or after.
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'CLS',
    value,
    selector: attribution.largestShiftTarget,
    sourceTime: attribution.largestShiftTime,
    loadState: attribution.loadState,
    url: location.pathname,
  }));
});

The payload is tiny, just a single beacon per page. Aggregate by selector in your warehouse and you get a ranked list of CLS offenders across your entire traffic, weighted by real frequency. The first time I added this to a media site, the top selector was .newsletter-signup-bar on article pages, a banner the marketing team had A/B tested in two weeks earlier without telling the perf team. The attribution data made the conversation easy.

Fix CLS from images and videos

This is the highest-leverage fix on the web. Every <img> and <video> tag needs width and height attributes set to the intrinsic dimensions of the source asset. Modern browsers compute the aspect-ratio from those attributes and reserve the correct slot, even when responsive CSS later resizes the image to width: 100%.

<!-- Right: browser reserves a 16:9 slot before the image loads. -->
<img
  src="/hero.avif"
  width="1280"
  height="720"
  alt="Course catalogue cover"
  style="width: 100%; height: auto;"
/>

<!-- Also right: pure CSS when you can't set HTML attributes (e.g. CMS limitations). -->
<style>
  .hero-image {
    aspect-ratio: 16 / 9;
    width: 100%;
    height: auto;
    object-fit: cover;
  }
</style>
<img src="/hero.avif" alt="..." class="hero-image" />

For responsive images served via srcset, set the width and height on the <img> to the dimensions of any single source. The aspect ratio is what matters, not the absolute size. For images of unknown dimensions (user uploads, third-party content), wrap them in a container with a min-height placeholder and replace it when the dimensions are known. Our image optimization guide covers AVIF, srcset, and Cloudinary-style automation in depth.

Fix CLS from web fonts

Font CLS happens when the fallback font renders first, then the web font swaps in with different metrics. Line heights change, paragraphs reflow, every byte of running text shifts. The 2026 fix has two parts: tune the fallback to match the web font's metrics, and choose a font-display strategy that matches your tolerance for invisible text.

/* Match the fallback's metrics to the web font using size-adjust + override descriptors. */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;          /* makes Arial glyphs the same size as Inter glyphs */
  ascent-override: 90%;       /* matches Inter's ascent */
  descent-override: 22%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: optional;     /* renders fallback for ~100ms; if Inter isn't ready, never swaps */
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}

The size-adjust and *-override descriptors are how you eliminate the swap shift entirely. When the web font finally loads, the line boxes are already the right size, so nothing reflows. Use Brian Louis Ramirez's fallback font generator to compute the override values for any pairing. Pair that with font-display: optional if the design tolerates a fallback flash and you want zero text-related CLS in the field. For deeper coverage of the preload, font-display, and unicode-range trade-offs, see our web font optimization guide.

Fix CLS from ads, banners, and injected content

The cardinal rule: never insert new content above existing content unless the user clicked something. If a cookie banner has to render, pin it to the viewport with position: fixed so the document flow doesn't change. If an ad slot has to render in-flow, pre-allocate the maximum slot height with min-height so the slot never grows the document. Same goes for newsletter bars, "as seen in" press strips, and A/B test variants: reserve space at first paint, fill it in later.

/* Reserve the worst-case slot size; the ad fills in without shifting siblings. */
.ad-slot-leaderboard {
  min-height: 250px;          /* matches the tallest creative size in your inventory */
  contain: layout;            /* prevents shifts inside the slot from propagating */
  display: flex;
  align-items: center;
  justify-content: center;
}

.ad-slot-leaderboard:empty::before {
  content: 'Advertisement';
  color: #999;
  font-size: 11px;
}

The contain: layout declaration is a 2026-grade upgrade. It tells the browser the slot's internal layout cannot affect anything outside it. Even if the ad's own DOM thrashes during rendering, no shift escapes into the page CLS. I hit this exact bug shipping a sponsored carousel last quarter; one line of contain dropped CLS from 0.14 to 0.02 instantly. For broader containment patterns, our content-visibility guide covers contain, content-visibility, and contain-intrinsic-size together.

For dynamically injected components (promotional bars, A/B variants, late-rendered comments), apply the same principle: render a skeleton at first paint, swap the real content in. The skeleton doesn't have to be pretty. A single empty div with the right dimensions is enough to stop the shift.

Why lab CLS differs from field CLS

This is the most common support question I get. Lab CLS (Lighthouse, PageSpeed Insights' "lab" tab, WebPageTest) measures only the initial page load up to a fixed timeout, in a controlled headless environment with no user interaction. Field CLS (CrUX, Search Console, your RUM) measures the entire page lifecycle on real devices, including post-load shifts triggered by scrolling, hovers, route changes, and viewport-unit recalculations as the mobile address bar collapses.

The result: lab CLS frequently reports 0.00 on pages where field CLS is 0.20. The lab missed the dropdown menu that opens on hover, the lazy-loaded image lower on the page, the cookie banner that auto-dismisses after 3 seconds, and the dozens of scroll-triggered shifts in a long article. The reverse also happens. Lab CLS catches a font swap that doesn't appear in field data because most users have the font cached.

Trust field data for SEO scoring and product decisions; use lab data for regression detection in CI. We wire both together via Lighthouse CI budgets: the lab catches new patterns introduced by a PR, the field catches what those patterns actually do at the 75th percentile. Performance work that only optimizes one of those will eventually surprise you in the other. For broader context on monitoring all three Core Web Vitals together, see our INP optimization guide and the LCP deep dive.

Frequently Asked Questions

What is a good CLS score in 2026?

A good CLS score is 0.10 or lower at the 75th percentile of real users, measured across both mobile and desktop. Between 0.10 and 0.25 is "needs improvement," and above 0.25 is poor. These thresholds have not changed since CLS was introduced, and Google's Core Web Vitals SEO scoring still uses the 75th percentile from CrUX as the source of truth.

Does CLS affect SEO rankings?

Yes. CLS is one of three Core Web Vitals factored into Google's page experience ranking signal. Sites that pass all three thresholds at the 75th percentile have a measurable advantage over sites that fail one or more, particularly on competitive commercial keywords. Ranking analyses after the March 2026 core update found borderline-failing pages losing one to two SERP positions.

Why is my Lighthouse CLS 0 but my field CLS is 0.2?

Lighthouse only measures shifts during the initial headless page load. Field CLS continues accumulating during scroll, interaction, and post-load content updates, exactly the situations that produce most real-world shifts. To reproduce field CLS in Lighthouse, manually scroll and interact with the page while a DevTools performance trace is running, or use the DevTools live Local Metrics view.

How do I fix CLS from web fonts?

Tune your fallback font with the size-adjust, ascent-override, descent-override, and line-gap-override descriptors so its metrics match the web font's. Then either preload the web font for predictable swap timing or use font-display: optional to skip the swap entirely. With matched metrics there's no reflow when the swap happens, eliminating font-related CLS.

Are shifts after a user clicks counted toward CLS?

No. Layout shifts that occur within 500ms after a user input (click, tap, keypress) are flagged with hadRecentInput: true in the Layout Instability API and excluded from the CLS score. The browser assumes those shifts are intentional consequences of the interaction. Long-running animations that continue beyond 500ms after the input do start counting again, so watch for accordion expansions that animate slowly.

What's the difference between CLS attribution and the standard web-vitals build?

The standard build returns only the CLS value. The attribution build (web-vitals/attribution) returns the same value plus the element selector responsible for the worst shift, the load state at the time of the shift, and the raw LayoutShift entry. The attribution build is about 2 KB larger and is the only practical way to debug field CLS at scale.

Alex Petrov
About the Author Alex Petrov

Web performance engineer who treats every millisecond as a personal challenge. Has profiled more sites than he can count.