CLS: Eliminating Layout Shift

Cumulative Layout Shift is the most nuanced Core Web Vital to understand and the easiest to accidentally introduce. Unlike LCP and INP, which measure time, CLS is a dimensionless score derived from the visual instability a user experiences as a page loads. When content jumps around — a button you're about to click suddenly moves down because an image above it finishes loading, or a cookie banner pushes the entire page — CLS is what captures that.

The formula for a single layout shift event is: impact fraction × distance fraction. The impact fraction is the proportion of the viewport affected by the shift. The distance fraction is how far the shifted element moved, as a proportion of the viewport's largest dimension. A large image loading in and pushing everything below it by half the page height would score close to 1.0 for that single event. CLS accumulates all such events across the page's entire lifespan.

Good is under 0.1. Needs Improvement: 0.1–0.25. Poor: above 0.25.

What Actually Causes Layout Shift

Understanding the root causes shapes your diagnostic approach. Most CLS comes from five patterns:

Images without explicit dimensions: When the browser encounters an <img> tag with no width and height attributes, it has no idea how much vertical space to reserve. It renders the image at zero height, lays out the content below it, then when the image downloads, it expands to its natural size — pushing everything below it down. This is the most common CLS cause on content-heavy sites.

Web font loading: Fonts take time to download. During that time, the browser either shows nothing (FOIT — Flash of Invisible Text) or falls back to a system font (FOUT — Flash of Unstyled Text). When the web font loads, the browser re-renders text in the new font, and if the metrics differ from the fallback, text reflows and adjacent content shifts.

Dynamically injected content: Anything inserted into the page above existing content — notification banners, cookie consent dialogs, "app install" prompts, personalized recommendations — will push content down, causing CLS.

Ads and embeds: Ad slots typically don't have fixed dimensions. An ad might load as a 50px banner or a 250px banner, and if you haven't reserved the appropriate space, everything below it shifts.

Iframes without dimensions: Like images, iframes without explicit width and height reserve no space until they load.

Diagnosing CLS in Chrome DevTools

The most efficient diagnostic tool is the Layout Shift Regions feature in Chrome DevTools. Open DevTools, go to Rendering (via the three-dot menu → More tools → Rendering), and enable "Layout Shift Regions." Now reload the page — every element that shifts is highlighted with a blue rectangle at the moment of shift.

The Performance panel also shows layout shift events as purple triangles in the Experience row. Click any triangle to see the CLS event details, including which element shifted and by how much. This narrows down which specific element to fix.

The web-vitals library reports CLS attribution in field data:

import { onCLS } from "web-vitals/attribution";

onCLS(({ value, attribution }) => {
  const { largestShiftTarget, largestShiftValue } = attribution;
  console.log(`CLS: ${value} — largest shift from ${largestShiftTarget}`);
});

Fix 1: Always Declare Image Dimensions

The fix for image-caused CLS is straightforward: always include width and height attributes on every <img> element. When these attributes are present, modern browsers use them to compute an aspect ratio and reserve the appropriate space before the image downloads — even if you're overriding the dimensions with CSS.

<!-- Bad: browser reserves zero height until image loads -->
<img src="hero.jpg" alt="Hero" />

<!-- Good: browser reserves 630px height (scaled to container width) -->
<img src="hero.jpg" alt="Hero" width="1200" height="630" />

The width and height values don't need to match the displayed size exactly — they just need to express the correct aspect ratio. The browser will scale them to fit the layout, maintaining the reserved space. CSS max-width: 100% or width: 100% on the image works fine alongside explicit dimension attributes.

If you're using Next.js, the <Image> component enforces this automatically and will error if dimensions aren't provided (or if fill prop isn't set for parent-constrained images):

import Image from "next/image";

// Next.js Image handles aspect ratio reservation automatically
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={630}
  priority // equivalent to fetchpriority="high" + preload
/>;

For responsive images with CSS aspect-ratio, the modern approach replaces the old padding-bottom hack:

/* Old padding-bottom hack — don't use this */
.image-wrapper {
  position: relative;
  padding-bottom: 52.5%; /* 630/1200 */
}
.image-wrapper img {
  position: absolute;
  width: 100%;
  height: 100%;
}

/* Modern CSS aspect-ratio — clean and correct */
.image-wrapper {
  aspect-ratio: 1200 / 630;
  width: 100%;
}
.image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Fix 2: Web Font Loading Strategy

The right font loading strategy depends on how closely your web fonts match available system fonts, and how much visual fidelity you need on first render.

font-display: optional is the strongest CLS-prevention option. It gives the font a very short load window (around 100ms). If the font doesn't load in time, the browser uses the fallback and never swaps — the web font will be used on subsequent page loads once it's cached. No font swap means no layout shift. The tradeoff is that on first visit to a cold cache, some users see the system font permanently for that visit.

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: optional;
}

font-display: swap with adjusted fallback metrics is a better option when you need the web font to always render. font-display: swap shows the fallback immediately and swaps when the web font loads — but if the font metrics differ significantly, the swap causes layout shift. The CSS size-adjust, ascent-override, and descent-override descriptors let you tune the fallback font's metrics to match the web font's, minimizing the visual jump:

@font-face {
  font-family: "Inter-Fallback";
  src: local("Arial");
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 107%;
}

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: swap;
}

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

Tools like Fontaine automate the generation of matching fallback metrics.

Preload critical fonts: For above-the-fold text, reduce the probability that the font loads after first render by preloading it:

<link rel="preload" href="/fonts/inter-regular.woff2" as="font" type="font/woff2" crossorigin />

The crossorigin attribute is required even for same-origin fonts — font fetch requests use CORS.

Fix 3: Reserving Space for Dynamic Content

When content is injected asynchronously — personalized recommendations, A/B test variants, cookie notices, or lazy-loaded sections — the DOM starts without that content, then expands to include it. If nothing reserves the space in advance, everything below the insertion point shifts down.

The correct approach is to always reserve space before the content is available. The min-height and aspect-ratio properties are your primary tools:

/* Reserve space for a banner that loads asynchronously */
.promo-banner {
  min-height: 80px; /* Minimum expected height */
}

/* Reserve space for a dynamic video embed */
.video-embed {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #f0f0f0; /* Visual placeholder */
}

For components that are in a loading state, skeleton screens are the right UX pattern. They maintain the layout structure while content is pending, preventing shift when real content arrives:

function ArticleCard({ isLoading, article }) {
  if (isLoading) {
    return (
      <div className="article-card skeleton">
        {/* Skeleton maintains the same dimensions as the real card */}
        <div className="skeleton-image" style={{ aspectRatio: "16/9" }} />
        <div className="skeleton-title" />
        <div className="skeleton-body" />
      </div>
    );
  }

  return (
    <div className="article-card">
      <img src={article.image} alt={article.title} width={800} height={450} />
      <h2>{article.title}</h2>
      <p>{article.excerpt}</p>
    </div>
  );
}

Fix 4: Ad Slots and Third-Party Embeds

Ad slots are notorious CLS sources because publishers often leave ad slot sizing to the ad network, which injects content at whatever height the winning ad happens to be. The fix is to define explicit minimum dimensions for every ad slot in your layout:

<!-- Ad slot with reserved space — prevents CLS regardless of ad size -->
<div class="ad-slot" style="min-height: 250px; min-width: 300px;">
  <!-- Ad code injected here -->
</div>

For responsive ad slots where the size varies by breakpoint, use CSS to set the appropriate min-height per breakpoint rather than leaving it undefined.

The same principle applies to third-party embeds: Twitter cards, Instagram posts, YouTube videos, Google Maps. Always specify explicit dimensions or use aspect-ratio to reserve the correct space.

CSS Containment and content-visibility

CSS containment helps CLS by isolating subtrees so that changes inside a contained element don't cause layout recalculation outside it:

.comment-thread {
  contain: layout style paint;
}

When a new comment is dynamically appended to .comment-thread, the browser's layout engine only needs to recalculate layout within that contained subtree. Elements outside .comment-thread are unaffected, so no CLS occurs for content above or below the comment thread.

content-visibility: auto defers rendering of off-screen content and can significantly improve initial rendering performance:

.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 1000px;
}

The contain-intrinsic-size property specifies the space to reserve for the element while it's not rendered. If this estimate differs significantly from the actual rendered height, the correction when the element enters the viewport will cause a layout shift — so use a reasonable approximation. The auto keyword in auto 1000px tells the browser to use the last-known rendered size after first render, reducing this issue on repeat visits.

Animations That Don't Shift Layout

Layout shift is specifically caused by changes to properties that affect layout — height, width, top, left, margin, padding. Animations that use only transform and opacity don't affect layout and don't cause CLS:

/* Bad: animating top causes layout shift — avoid */
.notification {
  transition: top 0.3s ease;
}

/* Good: transform doesn't affect layout, uses GPU compositing */
.notification {
  transform: translateY(-100%);
  transition: transform 0.3s ease;
}
.notification.visible {
  transform: translateY(0);
}

This applies to all entrance animations, slide-in panels, dropdown menus, and toast notifications. If the animation property affects how surrounding elements are positioned, it will cause CLS. Stick to transform and opacity for any animation that involves moving or showing elements.

Measuring CLS Accurately

CLS measurement has one important subtlety: the CLS value reported in Lighthouse (lab data) may differ significantly from CrUX field data. This is expected — lab data captures a single scripted page load, while CLS in field data accumulates across the entire page session including interactions.

Chrome 98 introduced a change to the CLS calculation: instead of accumulating all layout shifts throughout the entire session, Google now measures CLS in "session windows" of up to 5 seconds with gaps of no more than 1 second between shifts, and reports the worst-case window. This change improved scores for long-lived pages (news articles, SPAs) where CLS was accumulating from user-triggered content loads like infinite scroll.

Always verify your CLS status against Search Console's Core Web Vitals report, which reflects actual CrUX field data, rather than relying solely on Lighthouse scores.

The CLS Optimization Checklist

  1. Enable Layout Shift Regions in Chrome DevTools Rendering tab and identify which elements are shifting
  2. Add width and height attributes to every <img> element; use aspect-ratio CSS for responsive containers
  3. Migrate to Next.js <Image> component if using React — it enforces dimensions and prevents lazy-load CLS
  4. Set font-display: optional for non-critical fonts; use font-display: swap with adjusted fallback metrics for critical fonts
  5. Preload above-the-fold web fonts with <link rel="preload">
  6. Audit all dynamically injected content — cookie banners, personalization, A/B variants — and reserve space with min-height or aspect-ratio
  7. Replace loading state content with skeleton screens that match the target layout
  8. Set explicit min-height on all ad slots
  9. Specify dimensions on all third-party embeds
  10. Apply contain: layout style paint to isolated widget components
  11. Verify animations use only transform and opacity, not layout-affecting properties
  12. Check content-visibility: auto usage — ensure contain-intrinsic-size estimates are accurate
  13. Validate final CLS values in Search Console field data, not just Lighthouse