CLS and Layout Stability: Root Causes Guide
Cumulative Layout Shift (CLS) measures unexpected visual changes throughout a page's lifetime. A 0.15 CLS score means 15% of the viewport shifted unexpectedly. These shifts erode user trust, cause misclicks on buttons, and lower conversion rates—a user aiming for a checkout button might click a newsletter signup instead if the button moves. Common causes in React apps are late-loading fonts, missing image dimensions, ads inserting after content loads, and animations without fixed dimensions.
What Is CLS and How Is It Measured?
CLS is calculated as the sum of all individual layout shift scores. Each shift score is the fraction of the viewport that moved, multiplied by the distance it moved (in fractions of viewport height). A shift only counts if it is unexpected—shifts caused by user interaction (like opening a menu) are excluded.
Formula: Impact fraction × Distance fraction = Shift score
Example: If 20% of the viewport shifts by 10% of the viewport height, the shift score is 0.2 × 0.1 = 0.02. A page with five such shifts has a total CLS of 0.1 (0.02 × 5), which is "good."
Google's thresholds:
- Good: ≤ 0.1
- Needs improvement: 0.1–0.25
- Poor: > 0.25
Common CLS Causes in React
1. Late-Loading Fonts
Fonts are a major CLS culprit. When a @font-face loads after the page renders, text reflows and every element below it shifts down.
/* SLOW: Renders with fallback, shifts when custom font loads */
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto.woff2') format('woff2');
font-display: auto; /* Blocks text for up to 3 seconds */
}
body {
font-family: 'Roboto', sans-serif;
}
The browser renders text in the fallback font (serif), then 1–2 seconds later, the custom font loads and text reflows. The reflow is a layout shift.
2. Missing Image Dimensions
If an image does not have explicit width and height attributes, the browser allocates zero space for it initially, then reflows when the image arrives.
// BAD: No dimensions, causes CLS when image loads
<img src="/images/hero.jpg" alt="Hero image" />
// GOOD: Fixed dimensions, no layout shift
<img
src="/images/hero.jpg"
alt="Hero image"
width="1200"
height="600"
/>
Without dimensions, the page layout waits for the image to load before calculating the space, causing a shift. With dimensions, the browser pre-allocates space.
3. Ads and Embeds Inserting Late
Third-party ads, embeds, or dynamic content inserted after page load push other content down.
// BAD: Ads insert after page load, shifting content
export default function Article() {
const [ads, setAds] = useState([]);
useEffect(() => {
// Fetch ads after mount
fetch('/api/ads')
.then(res => res.json())
.then(setAds); // Inserts ad markup, shifts content
}, []);
return (
<>
<h1>Article Title</h1>
{ads.map(ad => <div key={ad.id}>{ad.markup}</div>)}
<article>Content...</article>
</>
);
}
// GOOD: Reserve space for ads upfront
export default function Article() {
return (
<>
<h1>Article Title</h1>
<div style={{ minHeight: '250px' }}>
{/* Ad placeholder */}
<AdSlot />
</div>
<article>Content...</article>
</>
);
}
By reserving space (with minHeight), the ad insertion does not shift content below it.
4. Animations Without Fixed Dimensions
Animations that grow or shrink elements can cause shifts if the layout is not pre-calculated.
// BAD: Expanding element shifts content below it
export default function Expandable() {
const [expanded, setExpanded] = useState(false);
return (
<>
<button onClick={() => setExpanded(!expanded)}>
Toggle
</button>
{expanded && (
<div>
Lots of content here that shifts everything below it
</div>
)}
</>
);
}
// GOOD: Reserve space with `max-height` and CSS transitions
export default function Expandable() {
const [expanded, setExpanded] = useState(false);
return (
<>
<button onClick={() => setExpanded(!expanded)}>
Toggle
</button>
<div
style={{
maxHeight: expanded ? '500px' : '0px',
overflow: 'hidden',
transition: 'max-height 0.3s ease',
}}
>
Lots of content here, no layout shift during animation
</div>
</>
);
}
Using max-height with CSS transitions avoids layout shifts because the space is reserved.
Measuring CLS with web-vitals
Use the getCLS() function to measure CLS in production:
// src/index.js
import { getCLS } from 'web-vitals';
getCLS(metric => {
console.log('CLS:', metric.value);
fetch('/api/analytics/cls', {
method: 'POST',
body: JSON.stringify({
metric: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
url: window.location.href,
timestamp: new Date().toISOString(),
}),
keepalive: true,
});
});
CLS is reported multiple times throughout the page lifetime (unlike LCP, which fires once). React calls the callback with updated CLS values as shifts occur.
Detecting Layout Shifts with PerformanceObserver
To identify which elements are shifting, use PerformanceObserver:
// src/utils/debugCLS.js
export function debugCLS() {
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
// Shift was unexpected (not caused by user interaction)
console.warn('CLS detected:', {
shiftScore: entry.value,
impactFraction: entry.impactFraction,
distanceFraction: entry.distanceFraction,
source: entry.sources, // DOM nodes that moved
timestamp: entry.startTime,
});
// Log the shifted elements
entry.sources.forEach(source => {
console.log('Shifted element:', source.node, {
x: source.currentRect.x,
y: source.currentRect.y,
});
});
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
}
// In your App component
import { useEffect } from 'react';
import { debugCLS } from './utils/debugCLS';
export default function App() {
useEffect(() => {
debugCLS();
}, []);
return (/* ... */);
}
The sources array contains the DOM nodes that shifted. Inspect them to identify which components are the culprits.
Common React Patterns Causing CLS
| Pattern | Issue | Fix |
|---|---|---|
| Conditional rendering | Content appears/disappears, shifts layout | Reserve space with containers or skeleton screens |
| List prepending | New items shift existing items down | Scroll to bottom for new items, or use virtualization |
| Late loading (images, data) | Content loads after initial render, shifts | Set explicit dimensions or placeholder sizes |
| Modals/overlays | Overlay shifts content behind it | Use position: fixed or position: absolute |
| Dynamic font loading | Text reflows when font arrives | Use font-display: swap to show fallback immediately |
Key Takeaways
- CLS measures unexpected visual shifts throughout the page lifetime (good: ≤ 0.1).
- Font loading is the #1 cause of CLS; use
font-display: swapto show fallback text immediately. - Always set explicit
widthandheighton images to pre-allocate space. - Reserve space for ads, embeds, and dynamic content with
minHeightor fixed containers. - Exclude user-initiated shifts by checking
hadRecentInputin PerformanceObserver. - Use CSS transitions and
max-heightfor animations to avoid layout shifts.
Frequently Asked Questions
Does conditional rendering in React cause CLS?
Yes, if not handled. If an element appears or disappears without reserving space, content shifts. Use placeholder divs, skeleton screens, or containers with fixed or minimum heights to avoid shifts.
How do I know if my CLS is caused by third-party scripts?
Use PerformanceObserver (above) to see which DOM nodes shifted. If shifts occur without any React state changes, it is likely a third-party script (ad, analytics, widget). Add display: none to the placeholder initially and display: block after the third-party loads.
Can I exclude certain shifts from CLS calculation?
CLS automatically excludes shifts caused by user input (clicks, scrolls, key presses). If you programmatically animate something that should not count as a shift, you can disable the layout-shift observation temporarily:
// Animate without triggering CLS
requestAnimationFrame(() => {
element.style.transform = 'translateY(10px)';
// transform does not cause layout shifts (no reflow)
});
Use transform (GPU-accelerated) instead of top, margin, or height to avoid shifts.
Should I use Skeleton Screens or actual content placeholder?
Skeleton screens (gray placeholders that match content shape) are better for UX—they give users visual feedback that content is loading. However, fixed-height containers work just as well for CLS prevention. Use whichever improves your UX.
Further Reading
- Web.dev: Optimize Cumulative Layout Shift — Comprehensive guide to CLS causes and fixes.
- PerformanceObserver Layout Shifts — MDN reference for detecting shifts.
- Font Loading Best Practices — How
font-displayoptions affect CLS. - Preventing Layout Shift — CSS techniques to stabilize layout.