View contents
Complete CLS Optimization Guide: Achieving Visual Stability
Introduction
Cumulative Layout Shift (CLS) measures the visual stability of your page. Achieving a good CLS score (under 0.1) requires understanding what causes unexpected layout shifts and implementing specific optimization techniques.
This guide covers advanced strategies based on Google’s official recommendations and real-world performance data to help you achieve and maintain excellent visual stability.
Understanding What Causes Layout Shifts
Before optimizing, you need to identify what’s causing shifts on your pages. According to web.dev documentation, the most common causes are:
Most Common CLS Causes (by impact):
├── 1. Images without dimensions (35-40% of issues)
├── 2. Ads, embeds, and iframes (25-30% of issues)
├── 3. Dynamically injected content (15-20% of issues)
├── 4. Web fonts (FOIT/FOUT) (10-15% of issues)
└── 5. Animations using layout properties (5-10% of issues)
Debugging Layout Shifts
Using Chrome DevTools Performance Panel
- Open DevTools (F12) → Performance tab
- Check “Screenshots” and “Web Vitals”
- Record a page load
- Look for red markers labeled “Layout Shift”
- Click each marker to see which elements shifted
DevTools Performance Panel:
┌─────────────────────────────────────────────────┐
│ Timeline: [===LS===LS==========LS====] │
│ ↑ ↑ ↑ │
│ shift 1 shift 2 shift 3 │
├─────────────────────────────────────────────────┤
│ Layout Shift Details: │
│ • Score: 0.08 │
│ • Element: <img class="hero"> │
│ • Previous Rect: (0, 0, 1200, 0) │
│ • Current Rect: (0, 0, 1200, 600) │
└─────────────────────────────────────────────────┘
Using Layout Instability API
Monitor shifts programmatically:
// Log all layout shifts with detailed information
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout Shift:', {
value: entry.value,
sources: entry.sources?.map(source => ({
node: source.node,
previousRect: source.previousRect,
currentRect: source.currentRect
}))
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
Using Web Vitals Library
import { onCLS } from 'web-vitals';
onCLS((metric) => {
console.log('CLS:', metric.value);
console.log('Entries:', metric.entries);
// Send to analytics
analytics.track('CLS', {
value: metric.value,
entries: metric.entries.length,
url: window.location.href
});
});
Image Optimization for CLS
Always Specify Dimensions
According to MDN Web Docs on responsive images, proper dimension handling is critical:
<!-- Method 1: Explicit width and height attributes -->
<img src="hero.jpg"
width="1200"
height="600"
alt="Hero image">
<!-- Method 2: CSS aspect-ratio -->
<style>
.hero-image {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
</style>
<img src="hero.jpg" class="hero-image" alt="Hero">
<!-- Method 3: Responsive with srcset (preserving aspect ratio) -->
<img src="hero-800.jpg"
srcset="hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 80vw,
1200px"
width="1200"
height="600"
alt="Hero image">
Using the Picture Element
For art direction with different aspect ratios:
<picture>
<!-- Mobile: Square crop -->
<source media="(max-width: 600px)"
srcset="hero-mobile.jpg"
width="600"
height="600">
<!-- Desktop: Wide crop -->
<source media="(min-width: 601px)"
srcset="hero-desktop.jpg"
width="1200"
height="600">
<img src="hero-desktop.jpg"
width="1200"
height="600"
alt="Hero">
</picture>
Container Aspect Ratio Pattern
Reserve space before image loads:
/* Container-based aspect ratio */
.image-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #f0f0f0; /* Placeholder color */
}
.image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
Handling Ads and Embeds
Reserve Space for Ad Slots
<style>
/* Fixed-size ad slots */
.ad-slot-leaderboard {
min-height: 90px; /* Standard leaderboard height */
width: 100%;
max-width: 728px;
background: #f5f5f5;
}
.ad-slot-rectangle {
min-height: 250px; /* Medium rectangle */
width: 300px;
background: #f5f5f5;
}
/* Use contain-intrinsic-size for better performance */
.ad-slot {
contain: layout style;
contain-intrinsic-size: 0 250px;
}
</style>
<div class="ad-slot ad-slot-rectangle">
<!-- Ad loads here -->
</div>
Sticky Ad Containers
Prevent ads from pushing content:
/* Sticky sidebar ad that doesn't shift content */
.sticky-ad {
position: sticky;
top: 20px;
min-height: 600px;
}
/* Or use absolute positioning within reserved space */
.ad-wrapper {
position: relative;
min-height: 250px;
}
.ad-wrapper .ad-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
Handling Embeds
<!-- YouTube embed with aspect ratio -->
<style>
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<div class="video-container">
<iframe src="https://www.youtube.com/embed/VIDEO_ID"
frameborder="0"
allowfullscreen
loading="lazy"></iframe>
</div>
<!-- Twitter embed with reserved space -->
<style>
.twitter-container {
min-height: 500px; /* Average tweet height */
contain: layout;
}
</style>
<div class="twitter-container">
<blockquote class="twitter-tweet">...</blockquote>
</div>
Dynamic Content Strategies
Above vs Below Viewport
Insert dynamic content below the viewport when possible:
// ❌ Bad: Insert at top, causes shift
function addBanner() {
const banner = createBanner();
document.body.insertBefore(banner, document.body.firstChild);
}
// ✅ Good: Reserve space first
function addBanner() {
const container = document.querySelector('.banner-slot');
container.style.minHeight = '60px'; // Reserve first
container.innerHTML = createBannerHTML();
}
// ✅ Best: Only add below viewport or with user interaction
function showNotification() {
const notification = createNotification();
// Append to bottom of page, doesn't shift existing content
document.body.appendChild(notification);
}
Skeleton Screens
Use skeleton loaders that match final content dimensions:
<style>
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-skeleton {
height: 300px; /* Match actual card height */
border-radius: 8px;
}
.text-skeleton {
height: 20px;
margin: 10px 0;
border-radius: 4px;
}
</style>
<div class="card-skeleton skeleton"></div>
Virtualized Lists
For long lists, use virtual scrolling:
// Concept: Only render visible items
class VirtualList {
constructor(container, itemHeight, totalItems) {
this.container = container;
this.itemHeight = itemHeight;
this.totalItems = totalItems;
// Set total height to prevent shift
this.container.style.height = `${itemHeight * totalItems}px`;
this.container.style.position = 'relative';
}
render(scrollTop) {
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(
startIndex + this.visibleCount,
this.totalItems
);
// Only render items from startIndex to endIndex
}
}
Font Loading Optimization
Preload Critical Fonts
<head>
<!-- Preload most important font -->
<link rel="preload"
href="/fonts/primary.woff2"
as="font"
type="font/woff2"
crossorigin>
</head>
Font-Display Strategies
/* Option 1: optional - Best for CLS (may not show custom font) */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional;
}
/* Option 2: swap with size-adjust - Good balance */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Adjust to match fallback */
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
/* Option 3: Use system font stack as fallback with matching metrics */
body {
font-family: 'CustomFont', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
Font Metric Overrides
Match fallback font metrics to custom font:
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto.woff2') format('woff2');
font-display: swap;
/* These values should match your custom font metrics */
ascent-override: 92.7%;
descent-override: 24.4%;
line-gap-override: 0%;
}
Animation Best Practices
Use Transform and Opacity
/* ❌ Causes layout shifts */
.animate-bad {
animation: slideIn 0.3s;
}
@keyframes slideIn {
from { margin-left: -100px; }
to { margin-left: 0; }
}
/* ✅ No layout shifts */
.animate-good {
animation: slideIn 0.3s;
}
@keyframes slideIn {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}
Hardware-Accelerated Properties
Only these properties don’t cause layout:
transformopacityfilterwill-change(use sparingly)
.smooth-animation {
/* Hint browser to optimize */
will-change: transform;
transition: transform 0.3s ease;
}
/* Or use contain for isolation */
.contained-animation {
contain: layout style paint;
}
Content-Visibility for Performance
Use content-visibility to skip rendering off-screen content:
/* Skip rendering until near viewport */
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
/* More aggressive for far below-fold content */
.footer {
content-visibility: auto;
contain-intrinsic-size: 0 300px;
}
Monitoring CLS in Production
Real User Monitoring (RUM)
import { onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
delta: metric.delta,
id: metric.id,
page: window.location.pathname,
entries: metric.entries.length
});
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', {
body,
method: 'POST',
keepalive: true
});
}
}
onCLS(sendToAnalytics);
Alerting on Regression
// Set up threshold alerts
onCLS((metric) => {
if (metric.value > 0.1) {
console.warn('CLS threshold exceeded:', metric.value);
// Send alert to monitoring system
alertMonitoring('cls_regression', {
value: metric.value,
threshold: 0.1,
page: window.location.href
});
}
});
CLS Optimization Checklist
Images & Media
├── [ ] All images have width and height attributes
├── [ ] Responsive images use srcset with sizes
├── [ ] Video embeds have aspect-ratio containers
├── [ ] Lazy-loaded images have placeholder dimensions
Dynamic Content
├── [ ] Ad slots have reserved minimum height
├── [ ] Embeds have aspect-ratio containers
├── [ ] Dynamic banners have pre-reserved space
├── [ ] Skeleton screens match final dimensions
Fonts
├── [ ] Critical fonts are preloaded
├── [ ] font-display: optional or swap is used
├── [ ] Fallback fonts have matching metrics
├── [ ] Font loading doesn't cause FOUT
Animations
├── [ ] Only transform and opacity are animated
├── [ ] No layout-triggering properties in animations
├── [ ] will-change used appropriately
├── [ ] contain used for animation isolation
Monitoring
├── [ ] CLS tracked with web-vitals library
├── [ ] Field data collected via RUM
├── [ ] Alerts set for CLS regressions
├── [ ] Regular audits with DevTools
Related Articles
- CLS Explained - CLS fundamentals
- LCP Optimization Guide - Optimize largest paint
- Image Optimization Guide - Complete image optimization
- Font Loading Guide - Advanced font strategies
References
This article was enhanced using authoritative sources:
[1] web.dev: Optimize Cumulative Layout Shift https://web.dev/articles/optimize-cls Google’s comprehensive guide to CLS optimization. Covers the main causes of layout shifts and specific techniques for each: image dimensions, ad handling, dynamic content insertion, font loading, and animation best practices.
[2] MDN Web Docs: Responsive Images https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images Technical documentation on implementing responsive images that maintain proper aspect ratios. Covers srcset, sizes, picture element, and proper dimension handling to prevent layout shifts.
[3] web.dev: Debugging Layout Shifts https://web.dev/articles/debug-layout-shifts Comprehensive guide to identifying layout shift sources using Chrome DevTools, Layout Instability API, and performance monitoring. Essential for systematic CLS debugging.
[4] HTTP Archive Web Almanac: Performance https://almanac.httparchive.org/en/2024/performance Industry data showing that images without dimensions account for 35-40% of CLS issues, with ads/embeds causing 25-30%. Real-world statistics inform optimization priorities.
Note: This article is part of our SEO analysis series. Explore all articles in the Performance SEO Hub.
Sources: web.dev (CLS Optimization), MDN Web Docs, HTTP Archive Web Almanac