notes 6 min layout
What moves a page after it has loaded
A CLS score says the page moved. It does not say what moved, and it says even less about what pushed it - which is the only part anybody can fix.
Somebody reaches for a link, the page jumps, and they press the thing that slid under their finger. Cumulative Layout Shift is the metric written for that moment, and it is built from a narrow definition: an element that was visible in one frame starts in a different place in the next, without the reader having asked for it.
Each shift is scored as two fractions multiplied together:
- Impact fraction
- How much of the viewport the moving elements covered, before and after, as one union. A full-width block that moves a little has a large impact.
- Distance fraction
- The furthest any of them travelled, divided by the viewport's larger dimension. A small element that jumps a long way scores here instead.
Shifts are then grouped into windows - bursts no more than a second apart, capped at five seconds - and the page's CLS is the worst window rather than the sum of everything. A long page that shifts a little forever is not punished for being long.
What does not count #
- Anything within half a second of an input. A click that opens an accordion moves everything below it, and that is the page doing what it was asked. Scrolling is not an input for this purpose, and does not shift anything either - it moves the viewport, not the elements.
-
Transforms. An element animated with
transformchanges where it is painted, not where it is laid out, so it never registers. Animatingtopormarginto the same effect does. - New elements appearing. Insertion alone is not a shift. The shift belongs to everything the insertion pushed down.
That last point is the whole difficulty. The element the metric reports is the victim, not the cause. A cookie banner inserted at the top of the page is not in the report at all; the hero, the heading and the first paragraph are.
The four usual causes #
- Images with no dimensions
- An
<img>with nowidthandheightis laid out at zero height until the file's header arrives, then grows to its real size and takes the text below it down. With both attributes present, every current browser derives an aspect ratio before a byte has loaded and reserves the space. - Fonts that arrive late
- With
font-display: swapthe fallback paints first. When the webfont lands, the same words take a different width, the line breaks fall in different places, and a paragraph gains or loses a line. Everything below it moves. - Content injected above what is already drawn
- Consent banners in the flow rather than over it, a promotional strip, a recommendation widget, a component that renders empty and fills itself once its data comes back. Anything inserted above the reader's position moves what they were looking at.
- Ad and embed slots with no reserved size
- An ad slot is usually an empty element until an auction finishes. An embed is an
<iframe>that resizes itself to its content. Neither knows its height at first paint, and both are commonly placed mid-article, where a shift lands under the reader's eye.
A fifth turns up on client-rendered pages: the server sends a skeleton, the script replaces it with real content of a different height. The skeleton was meant to prevent exactly that, and it does only if it is the same height as what replaces it.
Naming the element that moved #
Chromium exposes each shift through the Layout Instability API, with up to five of the elements that moved and where they were before and after. Firefox and Safari do not implement it, so this is a Chromium measurement - which is fine, because the layout that shifts is the same layout in every engine.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
for (const { node, previousRect, currentRect } of entry.sources) {
console.log(
entry.value.toFixed(4),
node,
`${previousRect.y} -> ${currentRect.y}`,
);
}
}
}).observe({ type: 'layout-shift', buffered: true });
buffered: true is what makes this useful after the fact: it replays the shifts that happened before the observer existed. A node of null means the element has since been removed, which is itself a clue - something was put in and taken out again.
- Take the topmost element in the report. It is the one closest to the cause.
- Look directly above it in the layout. Whatever sits there either grew or arrived.
- Check which of the four it was. An image with no attributes, a block of text in a webfont, an inserted element, an empty slot.
- Reload on a slow connection with the cache off. Most shifts are invisible on a fast machine with a warm cache and obvious on a phone.
The Shifts tool does that attribution while the page loads: it watches for elements that change position after paint and names each one with how far it went and what displaced it. Open it first and then reload - no tool can attribute a shift that happened before it was listening.
Fixes, in the order they usually apply #
<img src="/hero.avif" width="1600" height="900" alt="…">
<style>
img { max-width: 100%; height: auto; }
</style>
The attributes give the ratio, the CSS lets the image scale, and the box is the right shape from the first frame. For a container whose content is not an image - a video, an embed, a map - aspect-ratio on the container does the same job.
.ad-slot { min-height: 250px; }
.embed { aspect-ratio: 16 / 9; }
/* Over the page, not in it: nothing below has to move. */
.consent { position: fixed; inset: auto 0 0 0; }
- Reserve the largest likely size, not the smallest. A slot that collapses when it stays empty is a shift too, just in the other direction.
- Put late arrivals over the content or below the fold, never above the reader's position.
-
Animate with
transform, which does not count, rather than with properties that move the layout. - Match the fallback font's metrics to the webfont's, so the swap changes letterforms without changing line breaks.
The lab number and the field number will disagree, and both are right. A lab run measures one load; field data follows real visitors through the whole life of the page, including the lazy content that arrives when they scroll. A shift you cannot reproduce on load is very often one that happens on scroll.
Written by Ján Turský
Building LoupeKit and other browser tools out of Bratislava, under Apptiary.
run it here, no install
tools in this note