Cumulative Layout Shift (CLS) measures how much visible content shifts position during page load. Every time something on the page jumps — an image loads and pushes text down, an ad appears mid-scroll, a banner slides in from the top — CLS goes up. The threshold for "good" is under 0.1; "needs improvement" is 0.1 to 0.25; over 0.25 is poor.
CLS is the most fixable Core Web Vital because the causes are mechanical and well-understood. There are about six recurring patterns that cause shifts; this guide covers each one and the specific fix.
What CLS Actually Measures
The browser tracks every layout shift during page load — defined as any element moving from one frame to the next without user input. For each shift, it computes:
- Impact fraction — what fraction of the viewport is affected by the shift.
- Distance fraction — how far the largest element moves, as a fraction of the viewport.
- Layout shift score = impact fraction × distance fraction.
Individual scores are summed within "session windows" (5-second windows of activity), and the page's CLS is the maximum session window score during the entire visit.
Key point: shifts caused by user input (clicking a button, typing, scrolling) don't count. Only unexpected shifts.
Cause 1: Images Without Dimensions
The most common cause. An <img> tag without explicit width and height attributes (or CSS dimensions) takes up zero space until it loads — at which point the browser reflows everything below it.
The fix is to set explicit dimensions on every image:
<img src="hero.jpg" width="1200" height="630" alt="...">
Modern browsers use the width:height ratio to reserve space at the correct aspect ratio even before the image loads. As long as you set width and height (the values can be any units; browsers compute the ratio), there's no shift when the image arrives.
For responsive images:
<img src="hero.jpg" width="1200" height="630" style="width: 100%; height: auto;" alt="...">
The HTML attributes establish the aspect ratio; the CSS lets the actual display size adapt to container width.
Cause 2: Web Fonts
Custom web fonts cause "FOIT" (Flash of Invisible Text) or "FOUT" (Flash of Unstyled Text) — depending on settings, the browser either shows nothing until the font loads, or shows a fallback font that gets swapped when the custom font arrives. Both produce layout shifts as the text re-flows at different metrics.
Fixes:
- Use
font-display: swap:
Shows fallback immediately, swaps to custom when ready. Causes a shift but at least the user sees text.@font-face { font-family: 'Custom'; src: url('custom.woff2') format('woff2'); font-display: swap; } - Use
font-display: optional: Browser uses the custom font only if it's already cached or loads in the first ~100ms. Otherwise sticks with the fallback for the entire session. Eliminates the swap shift. - Match metrics with size-adjust:
Forces the fallback font to render at metrics matching the custom one, eliminating layout shift when the custom font arrives. Tools like the Font Style Matcher help you find the right values.@font-face { font-family: 'Custom'; src: url('custom.woff2') format('woff2'); size-adjust: 105%; ascent-override: 90%; descent-override: 22%; } - Preload fonts from the head:
<link rel="preload" href="custom.woff2" as="font" type="font/woff2" crossorigin> - Self-host fonts rather than loading from Google Fonts. Saves a DNS lookup and TLS handshake; fonts arrive sooner; less layout shift.
Cause 3: Ads, Embeds, Iframes Without Reserved Space
Display ads and third-party embeds load asynchronously, often hundreds of milliseconds after page render. If you don't reserve space for them, they push content down when they arrive.
The fix is to define a fixed-size container:
.ad-slot {
min-height: 250px; /* matches the ad's known height */
width: 100%;
}
For ads that come in multiple sizes, reserve space for the largest possible variant. The visible empty space before the ad loads is less painful than the layout shift after it loads.
For YouTube embeds, social media embeds, and other iframes — set explicit dimensions or aspect-ratio:
iframe {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
Cause 4: Dynamically Injected Content
JavaScript injects content above existing content — banner ads, "subscribe" prompts, GDPR cookie banners, sticky headers, A/B test variants. Every injection causes content below to shift.
Solutions:
- Reserve space upfront for content you'll inject. The JS knows the height of the banner; place a fixed-height placeholder in the HTML and replace it when the banner is ready.
- Don't inject above existing content. Banners and prompts can overlay (position: fixed) without pushing content. Cookie banners almost always work this way; do the same for other dynamic content.
- Inject before user interaction. Layout shifts within ~500ms of user input don't count toward CLS. If your script can fire injections in response to scroll, click, or input rather than load, the shift is forgiven.
Cause 5: Network Race Conditions
Sometimes content loads in unexpected orders. The CSS arrives, layout is computed, then a stylesheet that arrives later changes everything. Or a JS framework hydrates the page after initial render and reflows content differently.
For SPAs with hydration mismatches:
- Ensure server-rendered HTML and client-rendered HTML match exactly. React's "hydration mismatch" warning surfaces this.
- Avoid client-only rendering for above-the-fold content.
- Don't use
useEffectto set styles that affect layout — they fire after the initial render and cause shifts.
For CSS:
- Inline critical CSS in the head so the first paint has the right styles.
- Avoid late-loading stylesheets that change above-the-fold appearance.
- If you must lazy-load CSS, use
media="print"swap technique to load without blocking, but ensure the late-arriving styles don't change layout.
Cause 6: Animations and Transitions
Animations using top, left, width, height, margin, padding trigger layout reflows on every frame — and if they happen during page load, they count toward CLS.
Use only properties that don't trigger layout: transform (for position) and opacity (for visibility). Modern animations should use:
.slide-in {
transform: translateX(-100px);
transition: transform 0.3s;
}
.slide-in.active {
transform: translateX(0);
}
Not:
.slide-in {
left: -100px;
transition: left 0.3s; /* triggers layout shift */
}
The Diagnostic Workflow
- Run Site Speed Check for an instant CLS measurement.
- Use Chrome DevTools Performance Insights tab — it visually highlights every layout shift event and identifies the elements that moved.
- Lighthouse reports CLS along with diagnostics for the most impactful shifts.
- PageSpeed Insights field data shows real-user CLS — what Google actually measures for ranking.
The Production Habit
For new pages and components:
- Every
<img>has explicit width and height. - Every
<iframe>has aspect-ratio or explicit dimensions. - Every ad slot has a min-height.
- Web fonts use
font-display: swapat minimum, ideally with size-adjust. - Animations use only
transformandopacity. - Above-the-fold dynamic content is server-rendered or has placeholders.
None of these are difficult; they're disciplines applied consistently. Audit existing pages with Site Speed Check and fix the patterns that recur. Most sites can move from a CLS of 0.3+ to under 0.1 with a few hours of focused work.
Why CLS Matters Beyond Rankings
CLS is annoying. Mis-clicking the wrong button because the page shifted, losing your scroll position because content was injected above, having to re-find your place in an article — these are real user frustrations. Google measuring CLS is a proxy for measuring user-experience pain. The same fixes that improve your rankings improve your conversion rate, your bounce rate, and your real users' experience.
Run any URL through Site Speed Check and the CLS number tells you in seconds what kind of user experience you're delivering. Anything over 0.1 has fixes that pay off in both rankings and visitor sanity.