12 min read · Web Development · Last updated July 2026
Quick answer: CLS is almost always caused by one of seven fixable problems: images without width/height attributes, web fonts that cause text reflow, ad slots without reserved space, injected banners above existing content, layout-triggering CSS animations, iframe embeds without dimensions, or dynamic content insertion. Fix these in order and you’ll reach a CLS below 0.1.
Introduction
Of the three Core Web Vitals, Cumulative Layout Shift is the one that feels the most personal when it goes wrong. You’re reading a product description, the page suddenly jumps, and you accidentally click “Add to Cart” for the wrong item. Or worse — you’re on a mobile checkout page, an ad loads above the form, and you tap “Cancel” instead of “Confirm Order.”
CLS measures visual stability — specifically, whether elements on screen unexpectedly change position while the page is loading. The good news is that CLS has well-defined causes and equally well-defined fixes. Unlike LCP (which requires server-level changes) or INP (which requires JavaScript profiling), most CLS fixes are pure HTML and CSS changes that a developer can implement in a few hours.
This guide walks through every major cause of CLS with concrete code fixes and diagnostic tools to confirm each fix worked.
In this guide, you’ll learn:
– How CLS is calculated (impact fraction × distance fraction — and what that means in practice)
– How to diagnose CLS using Chrome DevTools and PageSpeed Insights
– The seven main CLS causes with specific fixes for each
– How to use CSS containment and the aspect-ratio property to prevent CLS at scale
Table of Contents
- How CLS Is Calculated
- Diagnosing CLS: Where Are My Layout Shifts?
- Fix 1: Image Width and Height Attributes
- Fix 2: Aspect-Ratio CSS for Responsive Images
- Fix 3: Font Loading and FOUT
- Fix 4: Reserving Space for Ads and Embeds
- Fix 5: Cookie Banners and Injected Content
- Fix 6: CSS Animations That Trigger Layout
- Fix 7: Iframe Embeds and YouTube Videos
- CLS Cause Identifier Widget
- Layout Shift Audit Checklist
- FAQ
- Conclusion
How CLS Is Calculated
Understanding the CLS formula helps you prioritize which shifts to fix first.
CLS = impact fraction × distance fraction
- Impact fraction is the proportion of the viewport that was occupied by the shifting elements — including both where they started and where they ended up. If a shifting element covers 70% of the viewport area, the impact fraction is 0.7.
- Distance fraction is how far the largest single element moved, divided by the viewport height. An element moving 200px on a 800px-tall viewport produces a distance fraction of 0.25.
A single layout shift where a hero image loads (covering 80% of the viewport) and pushes everything down by 30% of the viewport height scores: 0.8 × 0.3 = 0.24 — which lands squarely in “Needs Improvement” territory.
Session windows
CLS doesn’t simply sum all shifts on a page. Instead, Google groups shifts into session windows — periods of layout shift activity separated by at least a 1-second pause, with each window capped at 5 seconds. Your CLS score is the session window with the highest accumulated score.
This has a practical implication: a page with ten small shifts clustered in a 3-second window can score higher CLS than a page with one large isolated shift. Fixing a cluster of small shifts (images loading all at once) may be more valuable than fixing one large but isolated shift (a late-loading banner).
CLS thresholds
- Good: < 0.1
- Needs Improvement: 0.1 – 0.25
- Poor: > 0.25
Diagnosing CLS: Where Are My Layout Shifts?
Chrome DevTools — Layout Shift Regions
The most visual diagnostic approach:
- Open Chrome DevTools (F12)
- Click the three-dot menu → More tools → Rendering
- Enable Layout Shift Regions
- Reload your page
Areas affected by layout shifts are briefly highlighted in teal (blue-green). Watch the page load and note which elements flash teal — those are your CLS sources.
PageSpeed Insights — Diagnostics Section
PageSpeed Insights lists “Avoid large layout shifts” with the specific elements that shifted, their shift scores, and often a screenshot showing the before/after positions. This is often the fastest way to identify what’s shifting without watching the page manually.
Chrome DevTools — Performance Panel
Record a page load in the Performance panel. In the Experience row, look for red rectangles marked “Layout Shift.” Click each one to see:
– Which elements shifted
– The shift score (impact × distance)
– A before/after bounding box visualization
The PerformanceObserver API can also log shifts in real-time during development:
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
console.log("Layout shift:", entry.value, "Element:", entry.sources);
}
}
}).observe({ type: "layout-shift", buffered: true });
The hadRecentInput check filters out intentional shifts caused by user actions (scrolling, clicking) — Google excludes these from CLS scoring.
Fix 1: Image Width and Height Attributes
This is the single most common cause of CLS across the web, and the fix is a one-line HTML change.
When an <img> tag has no width and height attributes, the browser doesn’t know the image’s dimensions until it downloads it. The browser renders the page without reserving any space for the image. When the image loads, the browser inserts it, pushing all subsequent content down — a classic layout shift.
The broken pattern
<!-- Browser has no idea how much space to reserve -->
<img src="/product-hero.webp" alt="Product image">
The fix
<!-- Browser reserves exactly the right space before downloading -->
<img src="/product-hero.webp" alt="Product image" width="1200" height="800">
The width and height attributes don’t constrain your image layout (CSS still controls sizing) — they simply give the browser an aspect ratio to reserve space with. Modern browsers use these attributes to compute aspect-ratio before the image loads.
Important: Use the image’s intrinsic dimensions (the actual pixel dimensions of the file), not the display dimensions. If your image is 1200×800 pixels, use width="1200" height="800". The browser will display it at whatever size your CSS specifies, but use the aspect ratio from the attributes to reserve space.
When images have unknown dimensions (CMS-generated content)
For CMS-managed images where dimensions aren’t always known, use a CSS-only approach with a container that has a known aspect ratio:
.img-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.img-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
Or in responsive contexts, use aspect-ratio directly on the <img>:
img {
width: 100%;
height: auto;
aspect-ratio: attr(width) / attr(height); /* Uses the HTML attributes */
}
Fix 2: Aspect-Ratio CSS for Responsive Images
For responsive images where width: 100% is set in CSS, the browser still needs to know the aspect ratio to reserve height before the image loads. The modern solution is the CSS aspect-ratio property.
.hero-image {
width: 100%;
aspect-ratio: 16 / 9; /* or 4/3, 1/1, 3/2 — whatever your image ratio is */
object-fit: cover;
}
Combined with the width and height HTML attributes, the browser gets the aspect ratio from two sources — whichever it processes first lets it reserve space. This creates a robust fallback.
For truly dynamic aspect ratios (e.g., user-uploaded images in a social feed), use an intrinsic ratio container technique:
/* Intrinsic ratio container — works in all browsers */
.ratio-container {
position: relative;
padding-top: 56.25%; /* 9/16 * 100% = 56.25% for 16:9 */
}
.ratio-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
This technique works by using padding-top as a percentage (which calculates against the element’s width, giving you a height relative to width — maintaining aspect ratio). The image is then absolutely positioned to fill the container. No layout shift possible, regardless of when the image loads.
Fix 3: Font Loading and FOUT
Web font loading causes CLS through a phenomenon called FOUT — Flash of Unstyled Text. The browser renders text in a system fallback font while the custom web font loads. When the custom font arrives, it swaps in, changing text dimensions (different character widths, line heights, ascenders/descenders) — triggering a layout shift.
Understanding font-display values
@font-face {
font-family: "BrandFont";
src: url("/fonts/brand.woff2") format("woff2");
font-display: swap; /* Flash of fallback → swap to custom (causes CLS) */
font-display: optional; /* Use custom only if cached; otherwise use fallback — best for CLS */
font-display: block; /* Invisible text for up to 3s — worst for LCP */
font-display: fallback; /* 100ms invisible, then show fallback, swap if font arrives within 3s */
}
For body text: Use font-display: optional. If the custom font isn’t already cached, the browser uses the system fallback for the current page load. This eliminates FOUT entirely at the cost of showing the custom font only to returning visitors (who have it cached). Given that FOUT CLS often scores 0.05–0.15, this trade-off is almost always worth it.
For heading text (your LCP element): Preload the font AND use font-display: swap with fallback metric matching:
<!-- Preload critical heading font -->
<link rel="preload" href="/fonts/heading-bold.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: "HeadingFont";
src: url("/fonts/heading-bold.woff2") format("woff2");
font-display: swap;
/* Fallback metric adjustments to reduce reflow when swapping */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
The ascent-override, descent-override, and size-adjust descriptors modify the fallback font’s metrics to match the web font’s metrics, so when the swap happens, text renders at the same visual size — minimizing or eliminating the layout shift from font swap.
Self-hosting Google Fonts
Fonts loaded from fonts.googleapis.com require a DNS lookup, TLS handshake, and request to Google’s servers before the @font-face declaration is even available. This adds 100–300ms to font load time, extending the FOUT window.
Self-host by downloading the font files (use google-webfonts-helper.herokuapp.com) and serving them from your own domain or CDN. This eliminates the external request chain and allows you to add the preload tag.
Fix 4: Reserving Space for Ads and Embeds
Ad networks dynamically inject content into containers. If the container has no reserved dimensions, content shifts down when the ad loads. This is one of the most consistent CLS sources on ad-supported sites.
The problem
<!-- Ad container with no dimensions — ad load causes layout shift -->
<div id="ad-slot-header">
<!-- Ad script injects content here -->
</div>
The fix
/* Reserve the minimum space for the expected ad unit */
#ad-slot-header {
min-height: 90px; /* Leaderboard: 728×90px */
width: 728px;
max-width: 100%;
}
#ad-slot-sidebar {
min-height: 250px; /* Medium Rectangle: 300×250px */
width: 300px;
}
Set min-height to the height of the expected ad unit. If no ad loads, the container collapses after some delay (or you can use min-height with a background placeholder). If the ad is larger than expected, the container expands — but this won’t cause CLS if the ad slot is at the bottom of the page where no content is shifted below it.
For Google Ad Manager / AdSense, enable ad slot size auto-detection in your ad tag implementation and always specify the expected slot sizes in advance:
googletag.defineSlot("/network/ad-unit", [728, 90], "ad-slot-header")
.addService(googletag.pubads());
Fix 5: Cookie Banners and Injected Content
Cookie consent banners, GDPR notices, newsletter sign-up bars, and push notification prompts all share a common CLS failure mode: they’re injected into the page after initial render, above existing content, pushing everything down.
The wrong approach
// This runs after page load and inserts a banner above existing content
document.body.insertBefore(cookieBanner, document.body.firstChild);
When this runs after the browser has already laid out content, everything shifts down by the height of the banner.
Fix option 1: Reserve space for the banner in the HTML
If the banner always appears, add a container for it in your initial HTML so the browser reserves space from the start:
Then populate it with JavaScript. The space is already reserved; no shift occurs.
Fix option 2: Use position: fixed or sticky overlays
Instead of inserting content inline, overlay it as a fixed-position element that doesn’t affect document flow:
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
padding: 16px;
box-shadow: 0 -2px 12px rgba(0,0,0,0.12);
}
Fixed-position elements exist outside the normal document flow — they don’t push content around. A fixed-bottom banner is the CLS-safe pattern for cookie consent notices.
Other injection pitfalls
- Newsletter pop-overs: Use
position: fixedordisplay: noneuntil shown - Push notification prompts: Native browser prompts (the “Allow Notifications” dialog) don’t cause CLS — but custom overlay prompts can if injected inline
- Sticky headers that appear after scrolling: If a sticky header appears at the top and pushes content when it becomes sticky, use
position: sticky(which doesn’t trigger layout shifts) instead of togglingposition: fixedvia JavaScript
Fix 6: CSS Animations That Trigger Layout
CSS animations and transitions that change layout properties (not just visual ones) cause layout shifts because they trigger browser reflow — recalculating the position and size of affected elements and all their siblings.
Layout-triggering CSS properties (avoid for animation)
/* These properties trigger layout recalculation — cause CLS */
.bad-animation {
transition: height 0.3s, width 0.3s, padding 0.3s, margin 0.3s, top 0.3s, left 0.3s;
}
/* These properties are composited — do NOT trigger layout (safe for animation) */
.good-animation {
transition: transform 0.3s, opacity 0.3s;
}
Practical conversions
Before (layout-triggering):
.card:hover {
margin-top: -4px; /* Triggers layout — shifts surrounding elements */
}
.drawer.open {
height: 300px; /* Triggers layout — pushes content below */
}
After (composited, no CLS):
.card:hover {
transform: translateY(-4px); /* Composited — no layout trigger */
}
.drawer.open {
transform: translateY(0); /* Use translate instead of height change */
}
The general rule: prefer transform and opacity for all animations. Both are GPU-composited and don’t trigger layout or paint — they’re the only two CSS properties that are truly free of CLS risk.
Fix 7: Iframe Embeds and YouTube Videos
Embedded iframes (YouTube videos, maps, Twitter posts, Vimeo players) cause CLS when they load without reserved dimensions, pushing surrounding content.
The YouTube iframe problem
<!-- No dimensions — causes CLS when YouTube iframe loads -->
<iframe src="https://www.youtube.com/embed/videoID" frameborder="0" allowfullscreen></iframe>
Fix 1: Explicit dimensions with aspect-ratio container
Fix 2: lite-youtube-embed
The lite-youtube-embed library by Paul Irish is the recommended approach for YouTube embeds. It renders a thumbnail with a play button (using the YouTube thumbnail image, not the full YouTube player) and only loads the actual iframe when the user clicks play.
Benefits:
– No layout shift (the thumbnail has known dimensions)
– Significantly faster page load (YouTube’s player JS is ~500KB)
– CLS = 0 because dimensions are set before any dynamic content loads
For maps (Google Maps), use a static map image with a link to the interactive map instead of embedding the full Maps iframe — this eliminates the iframe entirely for users who don’t need interactivity.
CSS containment for complex components
CSS contain property tells the browser that an element’s subtree is independent of the rest of the document. This prevents layout shifts from inside the container affecting elements outside it:
.widget-container {
contain: layout; /* Internal layout changes don't affect external elements */
/* or */
contain: strict; /* Most aggressive containment */
/* or */
content-visibility: auto; /* Skips rendering off-screen content — also prevents shifts */
}
CLS Cause Identifier Widget
Answer a few questions to identify the most likely source of your CLS and get a targeted fix.
🔍 CLS Cause Identifier
Answer each question to pinpoint your most likely CLS source and get a specific fix.
Layout Shift Audit Checklist
✅ Layout Shift Audit Checklist
Check each fix you’ve implemented. Use this as a pre-launch CLS audit for any new page.
FAQ
Q: My CLS score fluctuates between “Good” and “Needs Improvement” in PageSpeed Insights. Why is it inconsistent?
CLS can be non-deterministic across test runs because it depends on which resources load first, network timing, and whether fonts/scripts are cached from previous visits. CrUX field data (the ranking signal) aggregates over 28 days and is more stable. Focus on field data trends rather than individual lab runs. Use Chrome DevTools Performance panel to watch for specific shifts that appear inconsistently.
Q: I fixed all my images with width/height attributes but CLS is still 0.15. What else should I check?
After images, the most common overlooked sources are: (1) web fonts causing FOUT, especially for above-the-fold headings; (2) a cookie consent banner loading above content; (3) a third-party ad or analytics script injecting content; (4) CSS height transitions on animated elements. Use Chrome DevTools Layout Shift Regions to identify exactly which element is still shifting — the visual highlight is usually the fastest diagnostic.
Q: Does CLS apply to Single-Page Applications (SPAs) built with React or Vue?
Yes. In SPAs, CLS can occur during route transitions if the new page content loads lazily and shifts layout. Skeleton screens (placeholder content at the right dimensions) are the recommended pattern — they reserve space while real content loads, preventing shifts. Also watch for CSS transitions that change element sizes during route animations.
Q: Does scroll-driven animation affect CLS?
Scroll-driven animations that use CSS animation-timeline: scroll() or JavaScript-based parallax effects that change transform properties do not cause CLS (transform is composited and excluded). However, scroll-based animations that change height, margin, or top/left/right/bottom do cause layout shifts and will affect CLS scores.
Q: We have a news site with a lot of ads. Can we realistically get CLS below 0.1?
Yes, and many ad-heavy news sites achieve it. The key is reserving ad slot heights before ad content loads. Major publishers (The Guardian, The New York Times) all pass CWV despite heavy ad inventory. Use specific ad unit dimensions in your CSS (min-height: 250px for medium rectangles, min-height: 90px for leaderboards), and position any “sticky” or “anchor” ads using position: fixed so they don’t affect document flow.
Conclusion
CLS is the most correctable of the three Core Web Vitals because its causes are well-understood and its fixes are almost entirely in HTML and CSS. Unlike LCP (which may require infrastructure changes) or INP (which requires JavaScript profiling), you can often take a site from 0.3 CLS to 0.05 in a single afternoon of targeted fixes.
Prioritize: fix images first (highest frequency), then fonts, then ads and embeds, then injected content. Use Chrome DevTools Layout Shift Regions throughout to confirm each fix actually eliminates the shift before moving to the next.
If your site has complex ad setups, custom CMS-generated content, or third-party embeds making CLS difficult to track down, the Ignited Nepal team can perform a full CLS audit and implement fixes with before/after field data tracking.
→ Talk to Ignited Nepal’s Web Development team
Written by the Ignited Nepal team. ignitednepal.com