The metric of being annoyed
Cumulative Layout Shift is the one vital that measures a feeling rather than a duration. It is the tap that lands on the wrong button because an ad arrived, the paragraph you were reading that jumped down the screen, the price that changed position as you were about to press Buy.
A single shift scores impact fraction × distance fraction. The impact fraction is how much of the viewport was involved — the area the element covered before and after, as one region, clipped to the screen. The distance fraction is how far it moved, over the larger side of the viewport. Both are fractions, so both are between 0 and 1, and so is the score.
Worked, so you can check it
Note the clipping. It is why a sideways shift of the same distance scores less than a vertical one on a full-width element: half of the union goes off the edge of the screen and does not count.
| value | |
|---|---|
| viewport | 390 × 844 = 329 160 px² |
| element before | x 0, y 220, 390 × 400 |
| element after | x 0, y 470, 390 × 400 — pushed down 250 px |
| union, clipped | y 220 → 844 (the bottom is off-screen), 390 × 624 = 243 360 |
| impact fraction | 243 360 / 329 160 = 0.7393 |
| distance fraction | 250 / 844 = 0.2962 |
| score | 0.2190 — this one shift is already over the 0.1 budget |
CLS is not the sum of everything
The reported CLS is the worst session window: shifts are grouped while no more than 1 second passes between them and the window is at most 5 seconds long, each window is summed, and the largest window wins. So ten separate small shifts spread over a minute do not add up into a failure; three shifts in the same second do.
It also keeps measuring for as long as the page is open — an infinite scroll that jumps on every batch fails in the field even though it looked perfect in the lab. And a shift within 500 ms of a user interaction is excluded: opening an accordion is not a layout shift, it is an answer.
The four causes, and the fix for each
img, video { aspect-ratio: attr(width) / attr(height); height: auto; }
.ad-slot { min-height: 250px; } /* reserved whether or not the ad ever arrives */
| Cause | Fix |
|---|---|
| An image with no dimensions | width and height attributes, or aspect-ratio — the browser reserves the box before the bytes arrive |
| Content injected above existing content (ads, banners, consent bars) | Reserve the height with min-height, and put it where it will not push — or overlay it |
| A web font swapping in at a different size | size-adjust / ascent-override on the @font-face, or font-display: optional |
Animating top / height / margin | Animate transform and opacity — they do not touch layout, so they do not shift anything |
Seeing it happen
entry.sources names the nodes. That is the whole debugging session: the element that moved is almost never the element that caused it — something above it grew.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // excluded from the metric
console.log(entry.value, entry.sources.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });