All posts
CLSCore Web VitalsPerformance

How to Fix Cumulative Layout Shift (CLS)

Layout shifts frustrate users and hurt your Google rankings. Learn how to diagnose exactly which elements are shifting and apply targeted fixes to reach a CLS score under 0.1.

debugutils.dev

A CLS score above 0.1 means your page is visually unstable — content is jumping around as it loads. Users click the wrong thing, lose their reading position, and leave. Google tracks this and factors it into your Core Web Vitals rankings.

Fixing CLS requires knowing which elements are causing shifts. Here’s how to diagnose and fix them.

Step 1: Find the Shifting Elements

The Chrome DevTools Layout Shift Regions tool highlights elements in blue when they shift. Enable it:

  1. Open DevTools → More Tools → Rendering
  2. Check “Layout Shift Regions”
  3. Reload the page

Or use the Performance tab and look for layout shift events (purple markers on the timeline).

Running a speed test on debugutils.dev shows your CLS score with the filmstrip — you can often spot exactly when a shift occurs by looking at the frame-by-frame screenshots.

Fix 1: Images and Videos Without Dimensions

The most common cause. Reserve space for images before they load:

<!-- Always specify width and height -->
<img src="product.webp" width="400" height="300" alt="..." />

<!-- Or use aspect-ratio in CSS -->
<style>
  .responsive-img {
    aspect-ratio: 4 / 3;
    width: 100%;
    height: auto;
  }
</style>

For responsive images, the browser calculates the correct height from the aspect ratio even if the display width is different.

Fix 2: Ads, Embeds, and Iframes

Ads that load after page content push everything down. Reserve the exact space they’ll occupy:

<!-- Reserve ad space -->
<div class="ad-slot" style="min-height: 250px; min-width: 300px;">
  <!-- Ad loads here -->
</div>

For YouTube embeds and other iframes, use the aspect ratio trick:

<div style="position: relative; aspect-ratio: 16 / 9;">
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    style="position: absolute; inset: 0; width: 100%; height: 100%;"
    loading="lazy"
  ></iframe>
</div>

Fix 3: Web Fonts and FOUT

When a web font loads, the browser redraws text — and the font metrics often differ from the fallback, causing a shift.

Use size-adjust to match fallback font metrics:

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

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

The size-adjust makes the fallback font match the custom font’s dimensions, so the swap is seamless.

Preload critical fonts:

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

Use font-display: optional: This only renders the custom font if it’s already cached — no fallback-to-custom swap on first visit.

Fix 4: Dynamically Injected Content

Cookie banners, notification bars, chat widgets, and promotions that appear above existing content cause large CLS scores.

Best approach: Include the banner in the initial HTML, even if hidden. Show/hide it with CSS (visibility: hidden → visibility: visible), not by inserting it into the DOM.

If it must be injected: Use position: fixed or position: sticky so it sits in a layer above the content without affecting layout flow.

Fix 5: Animations

CSS animations that modify layout properties (top, left, width, height, margin, padding) can cause layout shifts.

/* Bad: causes layout shifts */
@keyframes bounce {
  from { margin-top: 0; }
  to { margin-top: 20px; }
}

/* Good: composited, no layout shift */
@keyframes bounce {
  from { transform: translateY(0); }
  to { transform: translateY(20px); }
}

Properties safe to animate: transform, opacity, filter, clip-path. These run on the GPU compositing layer and don’t trigger layout.

Fix 6: Dynamic Content That Changes Page Dimensions

Lazy-loaded images below the fold: use loading="lazy" with explicit dimensions.

Accordions and expandable sections that animate open: wrap the content in a container with overflow: hidden and animate max-height rather than height. Note: animating max-height still triggers layout recalculation but is a common workaround. For a smoother approach, use CSS grid with grid-template-rows: 0fr transitioning to 1fr.

Skeleton screens: make sure your skeleton matches the exact dimensions of the loaded content. A skeleton that’s 50px shorter than the real card will cause a shift when the real content appears.

Verifying Your Fix

After making changes, run another test on debugutils.dev. The CLS score should drop. Look at the filmstrip to confirm no more visible shifts during load.

A score of 0 is achievable for most sites — target under 0.05 as a realistic goal.