Stop the Page Jumping While It Loads
Layout shift is almost always four causes. How to find which one you have in about a minute, and the CSS that fixes each.
Loading…
Layout shift is almost always four causes. How to find which one you have in about a minute, and the CSS that fixes each.
Loading…
Don't guess. Paste this into the console and reload — it prints each shift with the element that moved:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // user-triggered, doesn't count
console.log(entry.value.toFixed(4), entry.sources.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });
entry.value is that shift's contribution to CLS; anything over 0.01 is
worth chasing. The nodes logged are the ones that moved — which is usually
not the element causing the problem, but its neighbour.
The browser cannot reserve space for a box whose size it learns only when the bytes arrive.
<img src="/hero.jpg" width="1600" height="900" alt="" />
The attributes are enough — modern browsers derive aspect-ratio from them,
and CSS width: 100% still wins for the rendered size. For a container you
size yourself:
.thumb {
aspect-ratio: 16 / 9;
object-fit: cover;
}
The fallback font has different metrics, so text reflows when the real font lands.
@font-face {
font-family: "Rubik";
src: url("/rubik.woff2") format("woff2");
font-display: swap;
size-adjust: 96%; /* match the fallback's x-height */
}
font-display: swap avoids invisible text; size-adjust (plus
ascent-override/descent-override) is what actually removes the reflow, by
making the fallback occupy the same space. Next.js applies this automatically
for next/font, which is a good reason to use it.
Banners, consent bars and "you have 1 new message" strips that mount after hydration push everything down. Reserve the space before it arrives:
.banner-slot {
min-height: 4.8rem; /* the height it will be */
}
If the height is genuinely unknown, take it out of flow (position: fixed)
so it cannot move anything.
Transitioning height, top, margin or width re-runs layout on every
frame and drags siblings with it. Animate transform and opacity instead —
they run on the compositor and move nothing else:
/* costs layout every frame */
.panel { transition: height 200ms; }
/* free */
.panel { transition: transform 200ms; transform: translateY(-100%); }
To see what is moving without the observer, in DevTools open the Rendering panel and switch on Layout Shift Regions. Shifted areas flash blue as they happen — fastest way to catch a shift you cannot reproduce on demand.