Siddhant Deval
Siddhant Deval
frontend5 min read

CLS: Layout Stability & Compositor Cooperation

CLS is not about adding width and height attributes — it is about giving the browser's layout engine a reserved geometry contract before paint, so elements never shift reserved space, only fill it.

CLS: Layout Stability & Compositor Cooperation

Layout shift is not a visual quirk. It is an engineering failure at the geometry contract layer — the browser's layout engine did not know, before painting an element, how much space that element would occupy. When the element's actual size is discovered (image loads, font swaps, dynamic content injected), the engine recalculates geometry for every element that was displaced, and the visible result is a shift. The metric captures this as impact fraction × distance fraction — area × displacement — and sums it across the entire session.

The fix is never "stop animating." The fix is a geometry reservation: tell the layout engine, before paint, exactly how much space this element will occupy. aspect-ratio, min-height, font-display: optional, size-adjust — these are all geometry contracts, not style properties. Understanding CLS means understanding what information the layout engine needs before it can paint without shifting.


1. The Broken Pattern: Adding width/height and Still Failing CLS

HTML
<!-- ✅ Correct for static images — width and height prevent image CLS -->
<img src="/product.webp" width="800" height="600" alt="Product" />

<!-- The developer adds width and height, CLS still fails in production -->
<!-- Why? The CLS is not from the image — it's from the dynamic banner -->
JAVASCRIPT
// The actual CLS source — a cookie consent banner injected above existing content
// Runs after initial paint, pushes the entire page down
setTimeout(() => {
  const banner = document.createElement('div')
  banner.className = 'cookie-banner' // 80px height, injected at top of <body>
  banner.innerHTML = '<p>We use cookies.</p><button>Accept</button>'
  document.body.prepend(banner) // ← Shifts every element below by 80px
}, 2000)

// This single injection shifts the main content 80px downward
// On a 900px viewport: distance fraction = 80/900 ≈ 0.089
// If it affects 80% of the viewport: impact fraction = 0.8
// Shift score = 0.8 × 0.089 = 0.071 — almost the entire CLS budget in one injection

Engineers fix image dimensions and cannot understand why CLS remains at 0.19. The image was not the problem. The dynamically injected content above the fold was.


2. transform Animations vs. Positional Property Animations

This is the most common SDE-1 CLS surprise — and the most important rule to learn first:

CSS
/* ✅ Does NOT cause CLS — runs on the compositor thread */
/* The element moves visually but its layout position is unchanged */
.slide-in {
  animation: slideIn 300ms ease-out;
}
@keyframes slideIn {
  from { transform: translateX(-100px); }
  to   { transform: translateX(0); }
}

/* ❌ CAUSES CLS — triggers layout recalculation */
/* Every frame of this animation forces the browser to recalculate geometry */
.slide-in-broken {
  animation: slideInBroken 300ms ease-out;
}
@keyframes slideInBroken {
  from { left: -100px; }  /* ← layout property — triggers reflow every frame */
  to   { left: 0; }
}

/* ❌ CAUSES CLS — margin, padding, top, bottom all trigger layout */
.expand {
  transition: height 300ms;     /* ← height triggers layout */
}
.expand.open {
  height: 200px;               /* ← This transition causes CLS on every toggle */
}

/* ✅ Use max-height with overflow instead, or clip-path */
.expand {
  overflow: hidden;
  transition: max-height 300ms; /* Still triggers layout — clip-path is better */
}

/* ✅ clip-path — compositor-only, zero layout cost */
.expand {
  transition: clip-path 300ms;
  clip-path: inset(0 0 100% 0); /* Collapsed */
}
.expand.open {
  clip-path: inset(0 0 0 0);    /* Expanded */
}
Crucial Requirement

Properties that run on the compositor thread (transform, opacity, clip-path, filter) never trigger layout recalculation and cannot cause CLS. Properties that change an element's geometry in the document flow (top, left, margin, padding, width, height, border, font-size) always trigger layout and always risk CLS if they shift other elements.


3. CLS Defined: Impact Fraction × Distance Fraction

Layout shift score = impact fraction × distance fraction

Impact fraction:
  The fraction of the viewport area affected by the shift.
  If the shifted element + the space it moved from together cover 70% of the viewport → 0.70

Distance fraction:
  The fraction of the viewport height that the element moved.
  If an element moves 120px on a 900px viewport → 0.133

Shift score = 0.70 × 0.133 = 0.093

CLS = sum of all shift scores in the session, excluding shifts within 500ms of user input

Thresholds:

Score Threshold
✅ Good ≤ 0.1
⚠️ Needs Improvement ≤ 0.25
❌ Poor > 0.25

The hadRecentInput exclusion is significant: layout shifts caused by the user scrolling, tapping, or clicking within the last 500ms do not count toward CLS. Without filtering for this, scroll-driven animations and interactive expansions would make CLS meaningless.


4. Layout Shift Causes and Fixes

4.1 Images Without Reserved Dimensions

HTML
<!-- ❌ No dimensions — browser does not know the image's size until it loads -->
<!-- When the image loads, it pushes subsequent content downward -->
<img src="/product.webp" alt="Product">

<!-- ✅ Fixed width/height — browser reserves space before the image loads -->
<img src="/product.webp" width="800" height="600" alt="Product">

<!-- ✅ Responsive with aspect-ratio — more robust than fixed px values -->
<!-- aspect-ratio preserves the space at any container width via aspect-ratio CSS -->
<img
  src="/product.webp"
  width="800"
  height="600"
  style="width: 100%; height: auto;"
  alt="Product"
/>

Why aspect-ratio is more robust than fixed width/height for responsive layouts:

CSS
/* When the browser processes width="800" height="600" on a responsive image,
   it derives an intrinsic aspect ratio from those numbers.
   The `aspect-ratio` CSS property makes this explicit and composable: */
.hero-image {
  width: 100%;
  aspect-ratio: 16 / 9; /* Reserved at any width — no JS, no layout thrash */
  object-fit: cover;
}
/* Before the image loads: the element occupies width × (width * 9/16) of space.
   After the image loads: the element fills that pre-reserved space. Zero CLS. */

4.2 Dynamic Content Insertion

JAVASCRIPT
// ❌ Cookie banner injected at top — shifts entire page
document.body.prepend(cookieBanner) // 80px height, zero prior reservation

// ✅ Pre-reserved slot — content fills reserved space, does not shift
HTML
<!-- Reserve the banner slot in the initial HTML — zero CLS regardless of timing -->
<div class="consent-banner-slot" style="min-height: 80px; contain: layout;">
  <!-- Banner is injected here — into a pre-reserved container -->
  <!-- Other content is never displaced because the slot was always 80px tall -->
</div>

For ads and third-party embeds — the #1 real-world CLS source:

HTML
<!-- ✅ Reserve space for ad units using known creative sizes -->
<div class="ad-slot" style="width: 300px; height: 250px; contain: layout size;">
  <!-- Ad iframe injected here — slot was always 250px, no shift -->
</div>

<!-- ✅ For variable-size embeds, use aspect-ratio with overflow -->
<div class="embed-container" style="aspect-ratio: 16/9; overflow: hidden;">
  <!-- Social embed fills the pre-reserved 16:9 space -->
</div>

5. Font-Induced CLS

Web fonts cause CLS when the fallback font and the web font have different line heights, letter-spacing, or character widths — causing text blocks to reflow when the web font loads.

5.1 font-display Tradeoffs

CSS
@font-face {
  font-family: 'Geist';
  src: url('/fonts/Geist.woff2') format('woff2');

  /* font-display: swap
     — Text is visible immediately using the fallback font.
     — When Geist loads, the text reflows (potential CLS if metrics differ).
     — Good for readability; bad for layout stability. */
  font-display: swap;

  /* font-display: optional
     — The browser checks if the font is cached. If not, it uses the fallback forever.
     — Zero FOUT, zero CLS. The web font is never swapped in after initial paint.
     — Best for CLS; trade-off: first-time visitors always see the fallback font. */
  font-display: optional;
}

5.2 size-adjust for Zero-CLS Font Swap

size-adjust is a @font-face descriptor that scales the fallback font's glyphs to match the web font's metrics — eliminating the layout reflow without hiding the text:

CSS
/* Geist has slightly wider glyphs than Inter (the system fallback).
   Measure the difference using the Font Style Matcher tool:
   https://meowni.ca/font-style-matcher/
   Then adjust the fallback to match: */

@font-face {
  font-family: 'Geist-Fallback';
  src: local('Inter'), local('Helvetica Neue'), local('Arial');
  size-adjust: 104%;         /* Scale fallback up to match Geist's x-height */
  ascent-override: 90%;      /* Override ascent metrics to match Geist */
  descent-override: 22%;     /* Override descent metrics */
  line-gap-override: 0%;
}

body {
  /* Use the adjusted fallback first — identical metrics to Geist */
  font-family: 'Geist', 'Geist-Fallback', sans-serif;
}

/* Result: when Geist loads and replaces Geist-Fallback,
   the layout geometry is identical — zero CLS from the font swap */

6. Skeleton Placeholders and the Dimension Match Requirement

Skeleton screens are a common CLS mitigation — but only if the skeleton's dimensions exactly match the loaded content:

TYPESCRIPT
// ❌ Skeleton that collapses when content loads — causes CLS
function CommentList({ comments }) {
  if (comments.length === 0) return <SkeletonBlock height="auto" /> // height: auto → collapses
  return <ul>{comments.map(c => <Comment key={c.id} {...c} />)}</ul>
}

// ✅ Skeleton with matching reserved height — content fills the pre-reserved space
function CommentList({ comments }) {
  if (comments.length === 0) {
    return (
      // Reserve the same height the list will occupy when loaded
      // Use a stable height derived from the expected content count
      <div style={{ minHeight: `${ESTIMATED_COMMENTS_HEIGHT}px` }}>
        <SkeletonBlock />
      </div>
    )
  }
  return (
    <div style={{ minHeight: `${ESTIMATED_COMMENTS_HEIGHT}px` }}>
      <ul>{comments.map(c => <Comment key={c.id} {...c} />)}</ul>
    </div>
  )
}

7. Diagnosing CLS in Production

JAVASCRIPT
// Production CLS diagnostic — filter out user-initiated shifts
let sessionCLS = 0

new PerformanceObserver(list => {
  list.getEntries().forEach(entry => {
    // hadRecentInput = true means this shift was caused by user interaction
    // (within 500ms of a click, tap, or keydown) — exclude from CLS
    if (!entry.hadRecentInput) {
      sessionCLS += entry.value

      // Which elements shifted?
      entry.sources?.forEach(source => {
        console.log('Shifted element:', source.node?.nodeName)
        console.log('  Previous rect:', source.previousRect)
        console.log('  Current rect:', source.currentRect)
      })
    }
  })

  console.log('Session CLS so far:', sessionCLS.toFixed(4))
}).observe({ type: 'layout-shift', buffered: true })

In production, send the sources[0].node selector (built via getSelector(node)) to your analytics — it tells you exactly which element shifted and where it moved from/to, enabling precise attribution without needing a user reproduction.


Summary

Concept Rule
transform vs. positional transform, opacity, clip-path run on the compositor — no CLS. top, left, margin, height trigger layout — always risk CLS.
Score formula Impact fraction (viewport area affected) × distance fraction (distance moved / viewport height). Sums per session, excluding hadRecentInput shifts.
aspect-ratio More robust than fixed width/height for responsive images — reserves layout space proportionally at any container width.
font-display: optional Zero CLS — web font used only if cached; fallback used otherwise. Pair with size-adjust on the fallback @font-face to match metrics when swap is required.
Dynamic insertion Always pre-reserve the slot before injecting. Ads, cookie banners, and lazy-loaded content must fill a pre-sized container, not push content downward.
Skeleton precision Skeleton dimensions must match loaded content dimensions exactly. A skeleton that collapses on load causes a shift, not prevents one.

What's Next

In Part 9, we close the series with the React Compiler — the opt-in babel/SWC plugin that automates memoization across your entire component tree, and why your job as an engineer shifts from writing useMemo to ensuring component purity. Part 9: The React Compiler →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Core Web Vitals#CLS#Layout Stability#Performance#Web Fonts#aspect-ratio
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.