Layout shift

Stop the page moving under the reader: measure a shift the way the browser does, and reserve the space that caused it.

04of 16
≈ 12Min read
5Parts

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
viewport390 × 844 = 329 160 px²
element beforex 0, y 220, 390 × 400
element afterx 0, y 470, 390 × 400 — pushed down 250 px
union, clippedy 220 → 844 (the bottom is off-screen), 390 × 624 = 243 360
impact fraction243 360 / 329 160 = 0.7393
distance fraction250 / 844 = 0.2962
score0.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

the two lines that prevent most of it
img, video { aspect-ratio: attr(width) / attr(height); height: auto; }

.ad-slot { min-height: 250px; }   /* reserved whether or not the ad ever arrives */
CauseFix
An image with no dimensionswidth 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 sizesize-adjust / ascent-override on the @font-face, or font-display: optional
Animating top / height / marginAnimate 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.

the browser will tell you which element moved
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 });

Practice