Cumulative Layout Shift (CLS) Fixes: Fonts, Images, and Dynamic Content

Cumulative Layout Shift measures visual instability — how much the page content moves around while loading. Google uses it as a Core Web Vital, and a poor CLS score (above 0.25) directly affects rankings. The target is 0.1 or below at the 75th percentile. This article goes through every major cause with production-ready code fixes, covering images, web fonts, dynamic content, ads, and Drupal-specific patterns.

Measuring CLS

<!-- Add the web-vitals library (ESM, ~1.5 kB gzipped) -->
<script type="module">
  import { onCLS } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';

  onCLS((metric) => {
    // metric.value is the CLS score for the current page session
    // metric.entries are the individual layout shift events
    console.log('CLS:', metric.value, metric.entries);

    // Send to your analytics endpoint
    fetch('/analytics/vitals', {
      method: 'POST',
      body: JSON.stringify({
        name:  metric.name,
        value: metric.value,
        id:    metric.id,
        url:   location.href,
      }),
    });
  });
</script>

For quick diagnosis, open Chrome DevTools → Performance panel → record a page load. Layout shift events appear as purple bars in the "Experience" lane. Click one to see which element shifted and by how much.

Fix 1: Always Declare Image Dimensions

Undimensioned images are the single largest contributor to CLS globally. The browser does not know how much space to reserve until the image loads, causing content below it to jump.

<!-- Wrong: browser allocates 0px height until image loads -->
<img src="hero.jpg" alt="Hero image">

<!-- Correct: browser calculates aspect ratio from width/height -->
<img src="hero.jpg" alt="Hero image" width="1200" height="630">

For responsive images, pair width and height with a CSS rule that allows fluid sizing while preserving the ratio:

/* Global reset — preserves declared aspect ratio on flexible images */
img, video {
  max-width: 100%;
  height: auto;
}

This works because browsers infer the CSS aspect-ratio from the width and height HTML attributes and reserve the right amount of vertical space before the image bytes arrive.

Fix 2: Use CSS aspect-ratio for Unknown Dimensions

When the image's natural dimensions are not known at template time (user-uploaded content, API images), use the CSS aspect-ratio property:

.card__image-wrapper {
  width: 100%;
  aspect-ratio: 16 / 9;   /* Reserve the correct space */
  overflow: hidden;
}

.card__image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
<div class="card__image-wrapper">
  <img src="{{ image_url }}" alt="{{ image_alt }}" loading="lazy">
</div>

Fix 3: Embed SVG Intrinsic Sizes

<!-- SVG without viewBox causes zero-height allocation -->
<img src="logo.svg" alt="Logo">

<!-- Fix: add width/height to the SVG source -->
<!-- Inside logo.svg: <svg width="200" height="60" viewBox="0 0 200 60"> -->

<!-- Or use CSS to constrain the element -->
<img src="logo.svg" alt="Logo" width="200" height="60">

Fix 4: Web Font Layout Shifts (FOUT)

Web fonts cause layout shift when the fallback font has different metrics than the web font, causing text blocks to reflow. Two complementary fixes:

4a: font-display: swap with font metric overrides

/* Declare the web font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;   /* Show fallback immediately; swap when font is ready */
}

/* Override fallback font metrics to match Inter — eliminates the reflow */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  /* Values generated by fontaine or the Font Style Matcher tool */
  ascent-override:   90.20%;
  descent-override:  22.48%;
  line-gap-override: 0%;
  size-adjust:       107.40%;
}

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

Generate the metric override values with Font Style Matcher or the fontaine npm package.

4b: Preload critical fonts

<!-- In <head>, before your CSS -->
<link rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin>

Preloading ensures the font arrives before the first render, eliminating FOUT entirely for fast connections. Combine with the metric override fallback for slow connections.

Fix 5: Reserve Space for Ads and Embeds

/* Always reserve minimum ad slot height */
.ad-slot {
  min-height: 250px;   /* Standard IAB rectangle */
  width: 300px;
  background-color: transparent;
}

.ad-slot--leaderboard {
  min-height: 90px;
  width: 728px;
}

For variable-height ad providers, use a container that reserves the most common creative size and overflows gracefully:

.ad-container {
  min-height: 100px;
  contain: layout;   /* Prevent ad content from affecting surrounding layout */
}

Fix 6: Dynamic Content — Banners, Cookie Notices, Notifications

Cookie consent banners are one of the most common CLS offenders — they inject above existing content on load, pushing everything down.

/* Wrong: injecting a fixed-height bar that pushes content down */
.cookie-banner {
  position: relative;   /* Pushes page content down */
  top: 0;
}

/* Correct: overlay that does not affect document flow */
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 999;
}

For notification toasts and dropdowns, always animate with transform and opacity — never with height, top, or margin:

/* Wrong: animating height causes layout recalculation → CLS */
.notification { transition: height 0.3s; }

/* Correct: transform does not trigger layout */
.notification {
  transform: translateY(100%);
  opacity: 0;
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.notification.visible {
  transform: translateY(0);
  opacity: 1;
}

Fix 7: iframes and Embedded Content

<!-- Wrong: iframe with no dimensions -->
<iframe src="https://www.youtube.com/embed/xyz"></iframe>

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

Fix 8: Drupal-Specific CLS Sources

Toolbar height

The Drupal admin toolbar injects a bar at the top of the page for authenticated users. If your layout does not account for this, content shifts on login. Drupal core adds the toolbar-tray-open body class, but the toolbar height (39px) must be part of your design system:

/* Prevent toolbar from shifting sticky headers */
body.toolbar-fixed .site-header--sticky {
  top: 39px;   /* Drupal toolbar height */
}

body.toolbar-horizontal.toolbar-tray-open .site-header--sticky {
  top: 79px;   /* Toolbar + tray */
}

Lazy-loaded Drupal images

Drupal 9+ enables native lazy loading by default. Ensure all image fields include the width and height attributes in their formatter configuration:

<!-- In your Twig template, use the image_style filter which preserves dimensions -->
{% if content.field_image %}
  <div class="article__image"
       style="aspect-ratio: {{ content.field_image['#items'].first.width }} / {{ content.field_image['#items'].first.height }}">
    {{ content.field_image }}
  </div>
{% endif %}

Diagnosing Remaining CLS with PerformanceObserver

<script>
// Log every layout shift with the offending element
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (!entry.hadRecentInput) {
      entry.sources.forEach((source) => {
        console.log(
          'Layout shift:', entry.value.toFixed(4),
          'Element:', source.node,
          'Shift:', source.previousRect, '->', source.currentRect
        );
      });
    }
  });
}).observe({ type: 'layout-shift', buffered: true });
</script>

Run this on the problem page. The console output shows exactly which DOM node caused each shift and by how much.

Summary Checklist

  • Add width and height attributes to all <img> and <video> elements.
  • Set max-width: 100%; height: auto; globally so images scale without losing their aspect ratio reservation.
  • Use aspect-ratio CSS on image wrappers when natural dimensions are unknown.
  • Pair font-display: swap with ascent-override / size-adjust metric overrides on fallback fonts.
  • Preload critical web fonts with <link rel="preload" as="font">.
  • Reserve space for ads with min-height on ad slot containers.
  • Position cookie banners and notifications with position: fixed — never in document flow.
  • Animate with transform and opacity only — avoid animating layout properties.
  • Wrap iframes in aspect-ratio containers with position: absolute children.
  • Use the PerformanceObserver layout-shift API to identify specific offending elements.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.