Contents

Chapter 1

Cumulative Layout Shift (CLS) Optimization Guide

CLS measures visual stability — how much content moves around unexpectedly during the page's lifetime. A high CLS means elements jump around while the user is trying to read or interact, causing frustration and mis-clicks.


Targets

RatingCLS Score
Good≤ 0.1
Needs Improvement0.1 – 0.25
Poor> 0.25

CLS is a unitless score. It's calculated as: impact fraction × distance fraction for each layout shift, summed across the largest "session window" of shifts.


Common Causes and Fixes

1. Images Without Dimensions

The #1 cause of CLS. When an image loads, it pushes content below it down by the image's height. If the browser doesn't know the dimensions in advance, it allocates 0 height, then shifts everything when the image loads.

#### Before (Causes CLS)

html
<!-- No dimensions — browser allocates 0 height, then shifts on load -->
<img src="photo.jpg" alt="Team photo">

#### After (No CLS)

html
<!-- Explicit dimensions — browser reserves exact space before image loads -->
<img src="photo.jpg" alt="Team photo" width="800" height="600">

<!-- Or use CSS aspect-ratio for responsive images -->
<img src="photo.jpg" alt="Team photo"
     style="width: 100%; height: auto; aspect-ratio: 4/3;">
css
/* Modern CSS approach — works for any element */
.hero-image {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

/* For background images */
.hero-banner {
  aspect-ratio: 16 / 9;
  background-image: url('hero.jpg');
  background-size: cover;
}

2. Ads and Embeds Without Reserved Space

Ads, iframes, and third-party embeds are injected dynamically and often have unknown dimensions.

#### Before (Causes CLS)

html
<!-- Ad loads asynchronously and pushes content down -->
<div id="ad-slot-1"></div>
<article>Your content here...</article>

#### After (No CLS)

html
<!-- Reserve the maximum expected ad size -->
<div id="ad-slot-1" style="min-height: 250px; background: #f5f5f5;">
  <!-- Ad loads here — content below doesn't shift because space is reserved -->
</div>
<article>Your content here...</article>
css
/* For responsive ad slots, use aspect-ratio or container queries */
.ad-slot {
  min-height: 250px; /* Standard ad height */
  container-type: inline-size;
  background: #f5f5f5; /* Placeholder color so it doesn't look broken */
}

/* Adjust for mobile ad sizes */
@container (max-width: 400px) {
  .ad-slot {
    min-height: 100px; /* Mobile banner ad */
  }
}

3. Dynamically Injected Content

Content inserted above existing content pushes everything down.

#### Before (Causes CLS)

javascript
// Cookie banner injected at top of page after load — shifts everything
document.body.insertBefore(cookieBanner, document.body.firstChild);

// Notification bar added to the top after API response
header.prepend(notificationBar);

#### After (No CLS)

html
<!-- Reserve space for the banner in your HTML -->
<div id="cookie-banner-slot" style="min-height: 0; transition: min-height 0.3s;">
  <!-- Cookie banner will be injected here -->
</div>
<header>...</header>
<main>...</main>
javascript
// When showing the banner, animate the height instead of causing a jump
const slot = document.getElementById('cookie-banner-slot');
slot.innerHTML = bannerHTML;
slot.style.minHeight = '60px'; // Smooth transition, no shift

// When dismissing, animate back to 0
function dismissBanner() {
  slot.style.minHeight = '0';
  setTimeout(() => { slot.innerHTML = ''; }, 300);
}

Better approach: Place banners at the BOTTOM of the viewport (fixed position) so they don't shift any content:

css
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  /* Fixed elements don't cause layout shifts */
}

4. Web Fonts Causing FOUT/FOIT

When a custom font loads and replaces the fallback font, text elements can change size, causing shifts.

#### Before (Causes CLS)

css
/* Default behavior: text is invisible until font loads (FOIT) or
   snaps from fallback to custom font with different metrics (FOUT) */
@font-face {
  font-family: 'CustomFont';
  src: url('custom-font.woff2') format('woff2');
}

#### After (Minimal CLS)

css
/* Use font-display: optional — if the font doesn't load in ~100ms,
   the fallback is used for the entire page visit. No shift ever. */
@font-face {
  font-family: 'CustomFont';
  src: url('custom-font.woff2') format('woff2');
  font-display: optional;
}

/* If you must show the custom font, use size-adjust to match metrics */
@font-face {
  font-family: 'CustomFont Fallback';
  src: local('Arial');
  /* These values make Arial render at the same size as CustomFont,
     so when the swap happens, text doesn't shift */
  ascent-override: 92%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 105%;
}

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

5. Late-Loading Components that Resize

Accordions, tabs, and other components that change height after interaction.

css
/* Animate height changes smoothly instead of causing abrupt shifts */
.accordion-content {
  overflow: hidden;
  max-height: 0;
  transition: max-height 0.3s ease;
}

.accordion-content.open {
  max-height: 500px; /* Or use JavaScript to set exact height */
}
javascript
// Better: Animate to exact height to avoid wasted space
function openAccordion(panel) {
  const content = panel.querySelector('.accordion-content');
  // Get the actual height of the content
  content.style.maxHeight = content.scrollHeight + 'px';
  // After transition, remove maxHeight to allow dynamic content
  content.addEventListener('transitionend', function handler() {
    content.style.maxHeight = 'none';
    content.removeEventListener('transitionend', handler);
  });
}

6. Client-Side Rendered (CSR) Content

SPAs that show a loading skeleton, then replace it with content of different height.

css
/* Make skeleton elements match the height of real content */
.post-card-skeleton {
  height: 320px; /* Must match .post-card height */
  border-radius: 8px;
  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; }
}

/* Real card — same height as skeleton */
.post-card {
  height: 320px; /* Or min-height if content varies */
}

7. Fixed-Size Containers for Variable Content

When you know content will be injected but don't know the exact size:

css
/* Use contain: layout for components that shouldn't affect outside layout */
.widget-container {
  contain: layout;
  /* Content inside can change size without causing shifts outside */
}

/* Or use content-visibility for off-screen content */
.below-the-fold {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated height for layout */
}

CLS Debugging

Chrome DevTools

1. Performance tab: Record a page load → look for "Layout Shift" entries (red bars)

2. Shift each entry: Hover over it to see which element shifted and by how much

3. Rendering tab: Enable "Layout Shift Regions" — highlights shifted areas in blue during live interaction

JavaScript API

javascript
// Log every layout shift with the element that caused it
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) { // Ignore user-initiated shifts
      console.log('Layout shift:', entry.value.toFixed(4));
      console.log('Sources:', entry.sources?.map(s =>
        s.node?.nodeName + (s.node?.id ? '#' + s.node.id : '')
      ));
    }
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

Common CLS Debugging Pattern

Page loads → CLS detected → Which element shifted?
  → Image?      → Add width/height attributes
  → Ad/embed?   → Reserve space with min-height
  → Text?       → Fix font loading (font-display, size-adjust)
  → Dynamic?    → Reserve space or animate changes
  → Third-party → Wrap in a fixed-size container

Prevention Checklist

  • [ ] All elements have width and height attributes (or CSS aspect-ratio)
  • [ ] All elements have width and height attributes
  • [ ] All