TL;DR / CLS quantifies unexpected visual movement of page content by scoring how much visible elements shift position without user input, using a session window algorithm to find the worst burst of layout instability.
How It Works
Viewport
┌────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Header │ <- stable
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Image (no size) │ <- loads late, pushes text down
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ │ <- shifted! CLS scored
│ │ Text Content │ │
│ └──────────────────────────────────────────┘ │
│ │
│ │
│ │
└────────────────────────────────────────────────┘
CLS = sum of (impact fraction * distance fraction)
Session windows: 5s cap, 1s gap
Cumulative Layout Shift is a Core Web Vital that measures visual stability. It scores how much visible content moves unexpectedly during the page lifecycle, penalizing layout shifts that occur without user interaction.
Each individual layout shift produces a layout shift score calculated as: impact fraction * distance fraction. The impact fraction is the percentage of the viewport area affected by the shift -- the union of the element's before and after positions. If a 200px tall element shifts down by 100px in a 800px viewport, it occupies a 300px region (original 200px + 100px new area), so the impact fraction is 300/800 = 0.375. The distance fraction is how far the element moved relative to the viewport: 100/800 = 0.125. The shift score for that frame is 0.375 * 0.125 = 0.047.
CLS does not simply sum all shift scores across the session. Early implementations did, which unfairly penalized long-lived single-page applications that accumulated small shifts over hours. The current algorithm uses session windows: consecutive shifts are grouped into windows with a maximum duration of 5 seconds and a maximum gap of 1 second between shifts. If more than 1 second passes between two shifts, a new window begins. The CLS score is the maximum session window score -- the window with the largest total shift score, not the sum of all windows.
Layout shifts triggered within 500ms of user input are excluded. If the user clicks a button and content shifts as a result, that shift is expected and does not count toward CLS. The hadRecentInput property on layout shift entries reflects this exclusion. Note that scroll is not considered user input for this purpose -- elements shifting during scroll (e.g., sticky headers pushing content) are counted.
Measure CLS by observing type: 'layout-shift' entries with buffered: true. Each entry provides value (the individual shift score), hadRecentInput (boolean), startTime, and sources (an array of LayoutShiftAttribution objects identifying which elements moved and their before/after rects). Implementing the session window algorithm: maintain a current window, accumulate scores, start a new window when the gap exceeds 1s or the window exceeds 5s, and track the maximum window score.
Google's thresholds: Good is under 0.1, Needs Improvement is 0.1-0.25, Poor is above 0.25.
The most frequent CLS offenders are images and iframes without dimensions. When <img> elements lack width and height attributes (or CSS equivalent), they initially occupy zero space. When the image loads, the browser allocates space, pushing everything below downward. The fix is always specifying dimensions: <img width="640" height="480"> or using CSS aspect-ratio. Modern browsers use the width/height attributes to calculate an aspect ratio before the image loads, reserving the correct space.
Web fonts cause CLS through FOIT (Flash of Invisible Text) or FOUT (Flash of Unstyled Text). When a web font loads and replaces the fallback font, text blocks change size (different metrics between fonts), shifting surrounding content. Mitigations: font-display: optional (avoids swap entirely if the font does not load quickly), font-display: swap with size-adjust and metric overrides (ascent-override, descent-override, line-gap-override) on the fallback @font-face to match dimensions.
Dynamically injected content -- ad slots, cookie banners, notification bars, and lazy-loaded above-the-fold content -- shifts existing content when it appears. Reserve space with min-height on containers. For ad slots, use a fixed-size container matching the expected ad dimensions. For notifications, use overlays (positioned absolutely/fixed) rather than inserting into document flow.
CSS contain: layout prevents an element's internal layout changes from affecting siblings. Applied to known-shifting containers, it isolates the layout shift impact. content-visibility: auto similarly isolates offscreen content, preventing it from affecting layout when its size changes.
Gotchas
- CLS is measured across the entire page lifetime, not just load -- SPAs that inject content on route changes, show modals, or expand accordions accumulate CLS throughout the session
- Scroll-triggered shifts count toward CLS -- unlike user clicks/taps, scrolling does not set
hadRecentInput; elements that shift due to scroll position changes (lazy-loaded images, infinite scroll) affect CLS - Transform animations do not cause layout shifts --
transform: translateY()moves the visual representation without changing layout position; this is both a CLS optimization and a gotcha (the element visually moves but no shift is reported) - The session window algorithm must be implemented correctly -- simply summing all layout shift values produces an inflated score that does not match CrUX; implement the 5s cap / 1s gap windowing
sourcesattribution may be incomplete -- the browser reports at most 5 shifted elements per shift entry; in complex pages with many simultaneous shifts, some attributions are missing