Cumulative Layout Shift: Why Your Page Jumps and How to Stop It
You go to click a button. An image above it finishes loading and pushes everything down. You click the wrong thing. This interaction — universally frustrating — is what Cumulative Layout Shift (CLS) measures.
CLS is a unitless score that accumulates throughout the page's lifetime. Each layout shift contributes its shift score (a product of the impact fraction and distance fraction) to the total. A CLS under 0.1 is good. Over 0.25 is poor. A single unexpected shift of a large element can push an otherwise clean page into the poor range.
What Causes Layout Shift
The most common causes are elements whose dimensions are unknown before they load:
Images without dimensions: If an <img> has no width and height, the browser allocates zero space until the image loads, then reflowing the page.
Ads and embeds: Third-party ad iframes that resize after they load are among the most common CLS offenders on media sites.
Fonts causing FOIT/FOUT: Invisible text while a custom font loads (Flash of Invisible Text) followed by the text becoming visible and shifting surrounding elements.
Dynamically injected content: A cookie banner, newsletter signup, or promotional banner inserted above existing content pushes everything below it down.
Animations using non-composited properties: Animating top, left, width, or height triggers layout recalculation and can cause shifts in adjacent elements.
Fix 1: Always Set Image Dimensions
The most impactful single change. Setting width and height on all images allows the browser to reserve the correct space before the image loads:
<!-- ❌ No dimensions — causes layout shift on load -->
<img src="/photo.jpg" alt="Photo" />
<!-- ✅ Dimensions set — space reserved before image loads -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
With CSS aspect-ratio, you can set responsive images without breaking the reserved space:
img {
width: 100%;
height: auto;
aspect-ratio: 4/3;
}
In Next.js, the <Image> component enforces this automatically — it requires width and height props or fill layout.
Fix 2: Reserve Space for Dynamic Content
If you know an ad, banner, or async-loaded component is going to appear, reserve the space before it loads with a placeholder of the same height:
.ad-slot {
min-height: 250px; /* typical banner height */
background: #f5f5f5;
}
A container with known height prevents the shift when the content arrives. If the content turns out to be smaller, the extra whitespace is less harmful than the shift.
Fix 3: Control Font Loading
The font-display: optional descriptor tells the browser to use the fallback font if the web font is not available within a short time window. No flash, no shift:
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2');
font-display: optional;
}
font-display: swap is common but causes FOUTF (Flash of Unstyled Text with different metrics), which can cause shift. optional avoids this at the cost of first-visit users possibly seeing the fallback font.
The size-adjust, ascent-override, descent-override, and line-gap-override descriptors in @font-face allow you to match the fallback font's metrics to the custom font's metrics, eliminating the shift that occurs when fonts swap.
Fix 4: Avoid Inserting Content Above Existing Content
If you must show a cookie notice, chat widget, or notification banner, position it with position: fixed or slide it in from the edge rather than inserting it into the flow above existing content.
/* ✅ Fixed — doesn't affect document flow */
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
Fix 5: Use CSS Transforms for Animation
Layout-triggering properties (top, left, margin, padding, width, height) cause reflows that can shift adjacent content. transform and opacity are composited on the GPU and do not trigger layout:
/* ❌ Triggers layout and can cause CLS */
.slide-in { animation: slideDown 0.3s; }
@keyframes slideDown {
from { height: 0; }
to { height: 60px; }
}
/* ✅ No layout, no CLS */
.slide-in { animation: slideDown 0.3s; }
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
Measuring CLS
let cumulativeScore = 0
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) { // exclude user-initiated shifts
cumulativeScore += entry.value
console.log('Shift score:', entry.value, 'Total:', cumulativeScore)
}
}
}).observe({ type: 'layout-shift', buffered: true })
The hadRecentInput flag is important: shifts that occur within 500ms of user input (like expanding an accordion) are excluded from CLS because they are expected.
Conclusion
CLS is overwhelmingly caused by a handful of fixable issues: images without dimensions, ads without reserved space, and web fonts that swap metrics. Setting width and height on every image is the single change that eliminates the most CLS across the most pages. From there, reserving space for async content and constraining font fallback behavior handles the remaining cases. The combination of these fixes is usually sufficient to move a page from poor to good on CLS.
