Performance · Metrics
CLS (Cumulative Layout Shift)
⚡ TL;DR — CLS scores unexpected movement of visible content — the "I went to tap and it jumped" feeling. It's the product of how much of the viewport moved × how far it moved, summed over the worst 5-second session window. Good is ≤ 0.1. Almost every fix is the same idea: reserve the space before the content arrives.
🧠 Mental model
CLS is the only Core Web Vital that isn't measured in time — it's a unitless dimensionless score, and that trips people up. The formula for each shift:
layout shift score = impact fraction × distance fraction
└ share of viewport └ how far, as a
affected share of viewport
An image that pushes half the viewport down by a quarter of its height scores 0.5 × 0.25 = 0.125 — already over budget from one shift. "Cumulative" is a misnomer post-2021: the browser groups shifts into session windows (grow while shifts occur, cap at 5s, close after a 1s gap) and your CLS is the single worst window, not the lifetime sum. This is what makes infinite-scroll and long-lived SPAs survivable.
The word doing all the work is unexpected. Movement within 500ms of a user interaction is excused — clicking "show more" and having content appear is expected. CLS only punishes shifts the user didn't cause.
⚙️ How it actually works
The browser fires a layout-shift PerformanceObserver entry whenever a visible element changes its start position between two frames without a recent user input to explain it. The usual culprits:
- Images/videos/iframes with no dimensions. They render at 0 height, then reflow everyone below when bytes arrive. Setting
width/height(oraspect-ratio) lets the browser reserve the box up front — this alone fixes most CLS. - Web fonts (FOUT). The fallback font has different metrics; when the web font swaps in, every line re-lays-out.
size-adjust/font-displayand thef-mods(ascent-overrideetc.) tame this. - Injected content above existing content — banners, cookie bars, ads, "you have a new message" toasts that push the page down.
- Actions that measure then mutate — reading
offsetHeightthen inserting a node, causing a synchronous reflow the user sees.
Crucially, transform and opacity animations do not cause layout shifts — they run on the compositor and never move layout boxes. Animating top/height/margin does.
💻 Code
Reserve space so nothing reflows — the universal CLS fix:
<!-- ❌ Image with no dimensions: renders at height 0, then shoves the page down -->
<!-- ✅ Browser reserves a 16:9 box before a single byte loads -->
<!-- attrs give the ratio -->
/* Modern equivalent for responsive media and ad/embed slots */
.embed { aspect-ratio: 16 / 9; width: 100%; }
/* A dynamically-loaded ad/banner slot: reserve BEFORE it fills */
.ad-slot { min-height: 250px; } /* even empty, it holds its ground */
/* Fonts: match fallback metrics so the swap doesn't reflow text */
@font-face {
font-family: "Inter";
src: url(/inter.woff2) format("woff2");
font-display: optional; /* no swap-in reflow if it's slow */
size-adjust: 107%; /* line up with the fallback's metrics */
}
Animate the compositor-only properties — these are free of layout shift:
/* ❌ animating layout properties → shifts + jank */
.panel { transition: height .2s, top .2s; }
/* ✅ transform/opacity never touch layout */
.panel { transition: transform .2s, opacity .2s; }
⚖️ Trade-offs
- Reserving space costs whitespace on slow loads. A
min-heightad slot that stays empty leaves a gap — usually the right trade (a stable page beats a dense one that jumps), but it's a real cost to weigh. font-display: optionalcan mean your brand font never shows on a slow first visit (zero CLS, but a fallback face).swapshows the font but risks a shift. This is a genuine design-vs-metric decision, not a free win.- Skeleton screens fix perceived CLS only if the skeleton matches the final layout's box sizes. A skeleton that's a different height than the real content just shifts twice.
- Over-reserving hurts LCP/UX — huge placeholder gaps push your real content below the fold.
💣 Gotchas interviewers probe
- "Is CLS cumulative over the whole page life?" No — it's the worst 5-second session window. This is the single most common misconception, and it's why long pages aren't automatically doomed.
- The 500ms input exception. Shifts within 500ms of a user interaction don't count — so opening an accordion is free, but a banner appearing on its own is not.
transformdoesn't cause CLS. Candidates often "fix" a shift by animatingmargin; the right answer is to animatetransform, which never moves the layout box.- BFCache and back/forward used to produce phantom shifts; modern
web-vitalsresets the score onpageshow. Know that field CLS is per-page-view. - Late-loading fonts are a stealth CLS source — people blame images and miss the FOUT reflow entirely.
content-visibility: autocan cause shifts if the estimatedcontain-intrinsic-sizeis wrong — reserve the right size or it'll pop.
🎯 Say this in the interview
"CLS scores unexpected movement of visible content — the impact fraction times the distance fraction, summed over the worst 5-second session window, with a 0.1 target. The key nuance is 'worst window,' not lifetime cumulative, and that shifts within 500ms of a user action are excused, so it only punishes movement the user didn't cause. Practically, almost every fix is 'reserve the space before the content arrives': width and height or
aspect-ratioon all media,min-heighton ad and embed slots, and careful font loading so the FOUT swap doesn't reflow text. And the classic senior tell — if I need to animate something, I usetransformandopacity, which run on the compositor and never move layout boxes, instead of animatingtoporheight, which do. If I have to insert content dynamically, I put it below the fold or in pre-reserved space, never above what the user is already reading."
🔗 Go deeper
- web.dev — Cumulative Layout Shift (CLS) — the score formula and session-window definition.
- web.dev — Optimize CLS — the concrete fixes, ranked by impact.
- web.dev — Evolving CLS (session windows) — why "cumulative" became windowed in 2021.
- MDN —
aspect-ratio— reserving media boxes without magic-number padding hacks.