What is Cumulative Layout Shift (CLS)?
Cumulative Layout Shift (CLS) is a Core Web Vital that measures the total unexpected visual movement of page elements during loading.
What is Cumulative Layout Shift (CLS)?
Cumulative Layout Shift (CLS) is a Core Web Vital that measures how much stuff jumps around on your page while it's loading. Google shipped it in May 2020, started using it for rankings in June 2021. You want ≤0.1 (good). Anything >0.25 is poor. It's unitless — just a score.
The metric catches every unexpected layout shift that happens without user input. Images popping in without dimensions. Web fonts swapping. Ads shoving content down. CLS multiplies the impact fraction (how much of the viewport shifted) by the distance fraction (how far things moved).
In 2024, Google changed the calculation to use "session windows" — it groups shifts into max 5-second bursts with 1-second gaps between them, then reports the worst window's total. We've fixed CLS on probably 50+ production sites. It's the Core Web Vital that third-party scripts break most often.
How it works
CLS tracks layout shifts that happen without a preceding user interaction (click, tap, keypress). Each shift gets a score:
layout shift score = impact fraction × distance fraction
Impact fraction is the percentage of viewport area the unstable element occupies in both positions. Say a 200px element moves down 100px in an 800px viewport. The impacted area is 300px total, so impact fraction is 300 / 800 = 0.375.
Distance fraction is how far any element moved, divided by the viewport's largest dimension. Same example: 100 / 800 = 0.125. Shift score: 0.375 × 0.125 = 0.047.
Shifts group into session windows — bursts where each shift happens within 1 second of the previous one, max 5 seconds total. CLS reports the largest window's score.
You can measure it with:
- Lighthouse (lab data, simulated)
- Chrome UX Report (CrUX) (field data, real users)
web-vitalsJS library (v4.x) for RUM
import { onCLS } from 'web-vitals';
onCLS((metric) => {
console.log('CLS:', metric.value);
// Send to analytics
});
CrUX field data is what Google uses for ranking. Lab scores lie constantly because Lighthouse doesn't scroll or trigger lazy content like real users do.
When to use it
CLS matters everywhere, but some scenarios need extra attention.
Watch closely when:
- You've got above-the-fold images or video — always set explicit
widthandheightor use CSSaspect-ratio - Loading web fonts — font swap causes reflow. Use
font-display: optionalor preload critical fonts - Third-party ads or widgets inject dynamically — reserve space with min-height containers
- Using skeleton loaders — make sure placeholders match final dimensions exactly
- SPAs with client-side rendering — route transitions trigger shifts if content pops in
Less critical when:
- Pages are entirely static
- Only shifts happen after explicit user interaction (excluded from CLS by design)
- Internal tools where SEO doesn't matter (though UX still does)
Biggest wins we've seen? Setting image dimensions and reserving ad slot space. Every time.
CLS vs alternatives
CLS is one of three Core Web Vitals:
| Metric | Measures | Good threshold | Category |
|---|---|---|---|
| CLS | Visual stability | ≤ 0.1 | Layout stability |
| LCP | Loading performance | ≤ 2.5s | Perceived load speed |
| INP | Interactivity | ≤ 200ms | Responsiveness |
CLS is the only non-time-based metric — it's unitless. It's also the only one that accumulates throughout the page lifecycle, not just during initial load. This makes it uniquely annoying.
A page can pass CLS in Lighthouse but fail in CrUX because real users scroll and trigger lazy content that shifts layout. We've seen this break production launches more than once.
Before INP replaced FID in March 2024, CLS was the metric teams ignored most. Now that everyone's focused on INP, we're seeing CLS regressions creep back — especially on editorial sites with heavy content.
Real-world example
We worked on a Next.js 14 e-commerce site where product listing pages had CLS of 0.31. Poor range. Two culprits: product images using raw <img> tags instead of Next.js Image component (which handles dimensions automatically), and a promo banner loading 800ms after paint, shoving the product grid down 120px.
Fixes took two hours. Switched to next/image with width/height props. Added min-height: 80px to the banner container. Preloaded the banner API call.
CLS dropped to 0.04 in CrUX within the next 28-day collection window. Organic traffic to those listing pages went up 11% the following month. Partly the CWV fix, partly seasonal — we couldn't isolate it perfectly. But the correlation was there.