Detailed guide

Cls Optimization Guide

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

  1. Open DevTools (F12) → Performance tab
  2. Check “Screenshots” and “Web Vitals”
  3. Record a page load
  4. Look for red markers labeled “Layout Shift”
  5. 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:

  • transform
  • opacity
  • filter
  • will-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

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

Related articles

Related version

Introduction

Cls Explained

Cumulative Layout Shift (CLS) is one of Google's Core Web Vitals—a set of metrics that measure real-world user experience on your website

Category hub

Hub

Performance Seo Hub

Performance is a critical ranking factor and directly impacts user experience

In the same category

Detailed guide

Lcp Optimization Guide

Largest Contentful Paint (LCP) measures when the largest content element becomes visible to users

Detailed guide

Viewport Optimization Guide

The viewport meta tag is more than just a simple HTML element—it's the foundation of responsive web design and mobile optimization