What you'll learn: By the end of this lesson you will be able to say exactly which movements the browser counts as layout shifts and which it ignores, work out a shift score and a CLS value by hand, name the usual causes (images, embeds, injected content, fonts, animation, live updates) and the fix for each, find the element that moved in lab and field data, and design a page so late content has a place to land.
Think of a railway departures board. You find your train, start walking to the platform, and the board reshuffles: your train is two rows lower and you board the wrong one. The information was correct both times; what hurt was that it moved after you had started to act on it.
The Cumulative Layout Shift (CLS) metric counts that experience on a web page: visible content that moves while the page loads or updates, when the user did not ask for it. Loading Performance and Core Web Vitals introduced CLS next to LCP and INP. This lesson goes deeper on CLS alone: what the browser counts and ignores, why each common cause shifts, and how to build pages that stay still.
The running example is a product page: a gallery, a title and price, a promotional banner, a late-loading reviews widget, a web font, and a "Load more reviews" button.
Facts below were read on 2026-11-08 from the web.dev CLS page, the web.dev guide to optimizing CLS (last updated 7 February 2025), the WICG Layout Instability draft (a Community Group draft dated 6 November 2026, not a W3C standard) and MDN. At the time of writing web.dev rates CLS "good" at 0.1 or less and "poor" above 0.25, at the 75th percentile of page loads, segmented across mobile and desktop.
The core idea
A layout shift is a visible element that changes its start position between two rendered frames. CLS adds up the shifts the user did not cause. Two consequences organise the lesson.
- The browser can only keep content still if it knows the size early. Most shifts are a box that had no size when it was first laid out and got one later.
- Not every movement counts, and the fix depends on which kind you have. A box that grows without pushing anything is not a layout shift. A box that is animated with
transformis not either. A box that pushes the reader's content down is.
So the habit is: reserve space before content arrives, put late content where it moves nothing, and animate without changing layout.
What the browser counts, and what it ignores
Figure 1. Is this movement a layout shift that counts?
that moves nothing else)"| X["Not a layout shift"] B -->|"yes"| C{"Only a transform,
scrolling, or invisible?"} C -->|"yes"| X C -->|"no"| D{"Within 500 ms of a
discrete input?"} D -->|"yes"| E["hadRecentInput: reported
but excluded from CLS"] D -->|"no"| F["Counts: impact fraction
times distance fraction"]
How to read this: CLS measures start positions, not sizes. The same late widget scores zero if it is added below everything and a large number if it pushes the content the reader was looking at.
What the sources say about each step:
- Start position. Web.dev: layout shifts "only occur when existing elements change their start position". A new element, or an element that changes size, is not a shift unless it makes other visible elements move.
- Transforms and scrolling. Web.dev recommends
transformanimations because they do not trigger layout shifts. The draft is precise: an element counts as shifted only if both its transformed and its transform-indifferent start points move significantly, and scroll-driven movement is excluded. - Invisible elements. The draft requires the node to be visible with non-zero opacity in both frames.
- Recent input. An entry gets
hadRecentInputwhen the most recent excluding input was under 500 ms ago. The draft listsmousedown,keydown,pointerdownandchangeamong excluding inputs; scrolls andmousemovedo not exclude. Web.dev adds the principle: a shift after interaction is fine "as long as the shift occurs close enough to the interaction that the relationship is clear to the user". - Score.
score = impact fraction x distance fraction. The impact fraction is the combined visible area of all unstable elements in the previous and current frame, as a share of the viewport. The distance fraction is the greatest distance any unstable element moved, divided by the larger viewport dimension. - Frames and restores. The API does not report shifts inside iframes, but the metric counts them. A page restored from the back/forward cache should have its CLS reset to zero.
Illustration 1: the minimal case, scored by hand
A phone viewport of 400 by 800 CSS pixels (illustrative). The article body starts 100 px from the top. The page has an <img> above it with no width or height, so it takes no height until it loads. Then the image arrives and is 300 px tall.
<img src="/img/boot-1200.jpg" alt="Brown leather boot, side view">
<article>...</article>
The body moves from top 100 to top 400. The impact region is the body's visible area in both frames, which is 100 to 800, so 700 of 800 px of height: 0.875 of the viewport. The distance is 300 px over the larger dimension, 800 px: 0.375. Score: 0.875 x 0.375 = 0.328125, already above the "poor" line of 0.25 for CLS on its own. (Hand arithmetic with web.dev's formula, checked in Node; a real browser also applies a significance threshold and measures actual boxes.)
With width="1200" height="900" on the image, web.dev says modern browsers derive a default aspect-ratio from those attributes, so once CSS gives the image a width (width: 100%; height: auto;) the browser knows its height before the file arrives and the body never moves. If the attributes disagree with the real file, the auto keyword lets the real ratio win after download: a smaller shift, not zero.
Figure 2. An image without dimensions pushes the article down
How to read this: nothing is wrong with the download speed. The shift comes from laying out before the size was known. A faster image only shortens the window in which the reader can be misled.
CLS is the worst burst, not the sum
Shifts are grouped into session windows: a run of shifts with less than 1 second between each, capped at 5 seconds in total. Web.dev defines CLS as the score of the window with the largest cumulative score. Shifts flagged hadRecentInput are left out. The draft specification does not define windows: the API reports single shifts, and the draft treats cumulative scores in a non-normative section.
A small function makes the rule concrete. This ran in Node 26.12.1 on 2026-11-08 with invented shift values:
// Largest session window: a shift joins the current window if it comes less than
// 1000 ms after the previous shift and the window is under 5000 ms old.
function cls(shifts) {
const used = shifts.filter((s) => !s.hadRecentInput);
let best = 0, sum = 0, start = 0, last = 0, open = false;
for (const s of used) {
if (open && s.t - last < 1000 && s.t - start < 5000) {
sum += s.value;
} else {
sum = s.value; start = s.t; open = true;
}
last = s.t;
best = Math.max(best, sum);
}
return best;
}
const shifts = [
{ t: 900, value: 0.05 },
{ t: 1400, value: 0.04 },
{ t: 4000, value: 0.06 },
{ t: 4500, value: 0.07 },
{ t: 9000, value: 0.1, hadRecentInput: true },
];
Output: the counted shifts add up to 0.22, but cls(shifts) is 0.13, the second window (0.06 + 0.07). The first window is 0.09, and the shift at 9,000 ms is excluded as input-driven. This is a teaching sketch, not a replacement for the web-vitals library.
Figure 3. Where a shift lands in the windows
hadRecentInput"] --> X["Excluded"] W1 --> R["CLS = largest window = 0.13"] W2 --> R
How to read this: a long, calm page can have many small windows and a low CLS. One burst is what raises it.
Why each common cause shifts, and the fix
Every row below is the same story: something is laid out before its size is known, or something is inserted above content the reader is using.
| Cause | Why it shifts | Fix (web.dev's guide unless noted) |
|---|---|---|
| Images and video without dimensions | Laid out at 0 by 0 until bytes arrive | width and height attributes, or CSS aspect-ratio; MDN says explicit dimensions are especially important for lazy-loaded images, and notes that an unloaded image has a width and height of 0 |
| Ads, embeds, iframes, late widgets | Inserted after first layout | Reserve the slot in the initial layout with min-height or aspect-ratio |
| Content injected without a user action | New content pushes existing content | Placeholders or skeletons; put late content below the viewport; let the user trigger it ("Load more") |
| Web fonts | Fallback and web font have different metrics, so text reflows at the swap | font-display: optional, or a fallback tuned with size-adjust; preload the font so it can arrive before first paint |
| Animations | Animating top, left, width or height moves layout |
Animate transform |
| Pages that are not bfcache-eligible | A back navigation reloads the page and repeats its load-time shifts | Make pages eligible for the back/forward cache, which web.dev calls highly effective |
Figure 4. Choosing the fix from the element that moved
or place it below the viewport"] B -->|"no"| C{"Text reflowed at a font swap?"} C -->|"yes"| C1["font-display: optional or size-adjust,
preload the font"] C -->|"no"| D{"Moved during an animation?"} D -->|"yes"| D1["Animate transform, not layout properties"] D -->|"no"| E["Look at what inserted content above it:
user action, or a live update"]
How to read this: start from the measurement (which element, what loaded just before) and follow one branch. Guessing at images when the culprit is a font is the usual waste.
Details that matter in practice
- Fonts. MDN describes
font-display: optionalas an extremely small block period and no swap period, so a late font is simply not swapped in;swaphas an infinite swap period, which means a late swap can reflow text.size-adjustin@font-facescales a font's glyph outlines and metrics, so a fallback can be tuned to the web font's size. Web.dev's guide pairs it with related overrides. - Reserved space can be wrong. A
min-heightof 320 px for a widget that renders 400 px still pushes the content below it by 80 px: smaller than with no reservation, but not zero. Reserve the typical size, or the largest the slot will show. - Empty slots. A promo slot that is often empty reserves space that looks like a hole. That is a design decision: ask whether the banner can occupy a fixed strip or sit below the first screen.
- Off-screen sections. For an element that skips its contents while off screen (for example with
content-visibility: auto), MDN'scontain-intrinsic-sizesupplies a placeholder (auto 500pxuses 500 px until the real size has been rendered once, then remembers it). MDN notes that when the real size differs from the placeholder, a new layout may be forced as the element scrolls into view, so choose a close value.
Illustration 2: a realistic product page
The page loads in four steps: the server HTML with title and price; a hero image; a late reviews widget (a third-party script that injects its block); and a web font. In the starting version the gallery has no dimensions, the reviews block is inserted above the "Add to basket" button with no reserved height, and the font swaps after first paint. A single visit can show three bursts of shifts: image, then reviews, then font. The reserved-space version gives the gallery aspect-ratio: 4 / 3, the widget a min-height, and the heading font font-display: optional with a tuned fallback, and those shifts disappear.
Illustration 3: when "the user asked for it" still counts
A "Load more reviews" button fetches the next page. On a slow network the fetch takes 700 ms, and the rows are then inserted above the button the user is looking at, pushing it down.
The excluding rule is time-based: only shifts within 500 ms of a discrete input are flagged hadRecentInput. Web.dev adds that continuous interactions such as scrolling and dragging are not "recent input". So a response that arrives 700 ms after the click is counted, even though the click caused it, as are shifts caused by a timer or a live update.
The fix is not to rely on the exclusion. Insert a placeholder of the expected height at the moment of the click (inside the 500 ms window; the real rows then replace it without moving anything), or insert the rows below the button. Then check the largest shift's target in field data.
Measuring and debugging
- DevTools. Web.dev's guide says the Performance panel shows shifts in a Layout shifts track, grouping them into clusters; selecting one shows the elements that moved. The live metrics view lets you watch CLS while you interact. Lighthouse identifies shifted elements, but web.dev notes these are often the elements that were pushed rather than the ones that caused the movement, so look at what loaded just before.
- The API. Each
layout-shiftentry hasvalue,hadRecentInput,lastInputTimeandsources(up to five nodes, sorted by impact area, each with a previous and current rectangle). MDN marksLayoutShiftas limited availability and experimental, so feature-detect. - In the field. The
web-vitalslibrary'sonCLSworks in Chromium only, per its README. Withweb-vitals/attributionthe callback carrieslargestShiftTarget(a selector),largestShiftValue,largestShiftTimeandloadState(loading,dom-interactive,dom-content-loadedorcomplete). A large shift inloadingpoints at the initial HTML (images, fonts); one incompletepoints at late scripts and live updates. Report when the page becomes hidden: the README says the callback is always called then, and web.dev says CLS should be reported when a page is backgrounded.
Send these fields to your own collector (see the pipeline in the Core Web Vitals lesson), segment by page template and device, and compare windows by cause, not only the total.
Choosing fixes: trade-offs
| Fix | What it prevents | Cost or risk |
|---|---|---|
width and height or aspect-ratio on media |
Image and video shifts | Needs dimensions in the CMS or image pipeline; a wrong ratio still shifts a little |
Reserved slot (min-height) for widgets and ads |
Late-block shifts | Empty space if the slot stays empty; a wrong size shifts |
| Placeholder or skeleton | Late-content shifts | Extra markup; must match the final size |
| Load on user action | Unexpected insertions | One more click; slower perceived flow |
font-display: optional |
Swap reflow | Some visits never see the web font |
Tuned fallback with size-adjust |
Swap reflow | Metrics to maintain per font |
transform animation |
Animation shifts | Layout does not follow the animation; design accordingly |
| bfcache eligibility | Restored-page shifts | Auditing what blocks eligibility |
In practice: a support dashboard with live tickets
This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.
The situation
A support team uses a dashboard: a table of open tickets, a chart of tickets per hour, and a header with a web font. New tickets stream in and are inserted at the top. Agents report clicking the wrong ticket "because the table jumped". Field CLS at p75 on the dashboard template is 0.31 (illustrative), poor, and attribution points at the table rows, mostly with loadState of complete.
Starting architecture
Figure 5. Starting architecture: live rows and a chart with no reserved space
How to read this: every arrow into the browser ends in an insertion above or between content the agent is reading. None reserves a size first.
Options considered
| Option | Helps | Costs and risk |
|---|---|---|
| Reserve chart height and tune the font fallback | Load-time shifts | Cheap; does not touch the live rows |
| Buffer new rows behind a "3 new tickets" control | Live-update shifts | The agent clicks to reveal; a ticket can be missed if the control is ignored |
| Append new rows at the bottom | Shifts for readers at the top | Newest-first order is lost |
| Pause live updates while the pointer is over the table | Wrong-row clicks | Hidden state; delayed information |
The decision
| Field | Decision record |
|---|---|
| Context | Field CLS is poor and mostly from live row insertions after load; the chart and font add a smaller load-time share. |
| Decision | Reserve the chart's height and tune the heading font's fallback. Buffer streamed rows and show only a count pill; the rows are inserted when the agent clicks it. |
| Consequences, good | Content the agent is reading does not move without an action; load-time shifts disappear. |
| Consequences, bad | One more click to see new tickets; the buffer logic and its tests are new code. |
| Revisit when | Field p75 CLS is not under 0.1 two weeks after release, or agents report missed tickets. |
Figure 6. Target architecture: updates are buffered and slots are reserved
How to read this: the stream no longer writes to the table. Only the pill changes until the agent clicks.
Rollout and verification
- Ship the attribution reporting first with
largestShiftTargetandloadState, per template and release, to confirm the table is the cause. - Release the buffered table to a share of agents and compare field CLS for each group.
- Test the click path: insert the buffered rows within 500 ms of the click or with a placeholder, and check in the Performance panel that no shift remains.
- Add a CI check that fails a build when an
<img>lacks dimensions or a reserved slot loses itsmin-height.
The 2 a.m. failure
At 02:00 an outage elsewhere floods the queue and the stream pushes hundreds of tickets in a minute. The buffer works, but the pill's text changes width ("9 new" to "128 new") and shoves the filter controls next to it, so the CLS alert for the template fires. First checks: the attribution largestShiftTarget, the release marker and the stream rate. Mitigation: give the pill a fixed width and cap the text at "99+". Follow-up: add a load test of the stream at an outage rate and a review item for any element whose width depends on live data.
Architect's notes
- Make "where will this land" part of every spec for late content: a slot, a size, an owner.
- Treat dimensions as data, stored with every image in the CMS or image pipeline so the HTML can carry them.
- Separate load-time from live shifts. The first is fixed by reserving space, the second by design (buffering, user-triggered insertion).
- Do not lean on the 500 ms exclusion. It is narrow.
- Keep the element in your field data, with the load state.
Common mistakes
- Fixing images when the cause is a font or a widget, because nobody checked which element moved.
- Reserving a guessed size and never checking it against the real one.
- Inserting new content above the viewport content on a timer, a stream or a late response.
- Animating
top,left,widthorheightinstead oftransform.
Key takeaways
- CLS counts visible elements that change start position between frames, not size changes or additions that move nothing else.
- Score = impact fraction x distance fraction; CLS is the largest session window (gaps under 1 s, at most 5 s), not the sum.
- Transforms, scrolling, invisible elements and shifts within 500 ms of a discrete input are not counted.
- Reserve space before content arrives: dimensions or
aspect-ratiofor media,min-heightfor slots, placeholders for late content. - Fonts shift through metric differences:
font-display: optionalor a tuned fallback. - Live updates need design, not a slot: buffer them or insert them where they move nothing.
- Measure with the element and the load state so alerts point at the cause.
Practice exercises
Exercise 1 — Starter (30 min): Take a page you know, open the Performance panel and find its layout shifts, if any. For each, name the element that moved and what loaded just before it.
Exercise 2 — Intermediate (2 hours): Build a page with an image above an article. Remove the dimensions, record the shift, compute the score by hand, then add width and height and compare. Try a wrong ratio on purpose and note the remaining shift.
Exercise 3 — Intermediate (2 hours): Write a function that computes CLS from a list of shifts with session windows and hadRecentInput, and test it with cases at exactly 1,000 ms and 5,000 ms. State where the draft specification does and does not define the rule.
Exercise 4 — Advanced (half day): Design the "Load more" and "new tickets" interactions so that no inserted content moves what the user is reading, with a timing diagram. Which shifts would still count?
Related reading
In this course:
- Loading Performance and Core Web Vitals — the three metrics and the monitoring pipeline.
- Optimizing Largest Contentful Paint — the late hero image.
- The Critical Rendering Path — when layout happens.
- The Event Loop and the Main Thread — late scripts and long tasks.
- HTTP Caching for the Front End — caching fonts and images.
- Rendering Strategies: CSR, SSR, SSG, ISR and Streaming — what the first HTML holds.
Elsewhere on the site:
- System Design: Observability and SLOs — percentiles and alerting.
Official documentation
- web.dev: Cumulative Layout Shift (CLS) — definition, score, windows (read 2026-11-08).
- web.dev: Optimize Cumulative Layout Shift — causes and fixes, updated 7 February 2025 (read 2026-11-08).
- WICG: Layout Instability — Community Group draft of 6 November 2026.
- MDN: LayoutShift — properties and availability.
- web-vitals library —
onCLSand the attribution build. - MDN: contain-intrinsic-size — placeholder sizes for skipped content.