What does the Cumulative Layout Shift (CLS) metric measure on a web page, and which kinds of on-screen movement does it deliberately not count?
answer
- unexpected movement, not all movement
- a score, not seconds or pixels
- recent click buys a short exemption
- transform moves cost nothing
- new element pushes, does not shift
basics
~20 sCLS scores how much visible content moves unexpectedly during a page's life. It ignores movement the user just triggered — shifts within 500 milliseconds of a click, tap or key press — and elements moved with CSS transforms.
solid answer
~50 sCLS is the Core Web Vital for visual stability. It looks for *unstable elements*: elements that were visible in one rendered frame and start at a different position in the next one, without the user having asked for that. Each such movement gets a small unitless score based on how much of the viewport was affected and how far things moved, and CLS reports the worst burst of those scores over the page's lifetime. Two big carve-outs keep it honest. Movement within 500 ms of a discrete user input — a tap, click, or key press — is flagged `hadRecentInput` and excluded, because opening an accordion is expected. And elements animated with CSS `transform` are not layout shifts at all, since their layout position never changed. An element merely appearing, or growing with nothing below it, also scores nothing until it pushes other content.
go deeper
Be able to say plainly that CLS is about content jumping around, that it is a unitless score rather than a time, and that movement right after the user clicks something is not counted.
Explain the definition mechanically: an already-visible element whose start position changes between two frames, why an element that merely appears scores nothing for itself, and why transform animations are free while animating height is not.
An interviewer expects you to go from a bad score to a named cause. Talk about reading layout-shift entries and their sources, and about the fact that the metric keeps accruing for the whole document lifetime, not just the load.
Own the framing: visual stability is a design and delivery constraint, not a bug to be fixed late. Be ready to argue for rules — reserved space for anything asynchronous, no injection above existing content — enforced at the component and template level rather than per incident.
## The problem the metric exists for Every user has hit this: you go to tap a link, an ad or a banner loads above it, the page jumps, and you tap something else. Nothing about that experience shows up in a loading metric — the page may have painted quickly and responded to input quickly. CLS is the Core Web Vital that puts a number on this specific failure: **visible content moving when the user did not ask for it**. ## What the browser actually watches The browser compares consecutive rendered frames. An element is an **unstable element** for a frame if it was visible in the previous frame and its start position — the top-left of its box in the viewport, in the writing direction — is different in the current frame. Note the three conditions hiding in that sentence: - **Visible.** Content scrolled far out of the viewport is not part of the shift, because the user could not have been reading it. - **Was there before.** An element that did not exist in the previous frame has no previous position, so it cannot be an unstable element. Injected content does not score for *itself*; it scores because of the elements it **pushes**. - **Start position changed.** An element that only grows or shrinks, with nothing below it to displace, is not shifting. A card that gets taller at the bottom of the page moves nothing. Each shift gets a small unitless score derived from how much of the viewport was involved and how far the content travelled, and those scores accumulate over the page's life. ## What does not count Three exclusions come up constantly in interviews. **User-initiated movement.** A shift that occurs within 500 ms of a discrete user input — tap, click, key press — is exempt. The `layout-shift` performance entry carries a `hadRecentInput` boolean for exactly this, and shifts with it set contribute nothing. Expanding a menu, opening an accordion, or toggling a filter is movement the user requested and is watching for. **Transform-based movement.** If you move an element with `transform: translateY(...)`, its layout position never changed — the browser only draws it somewhere else. That is not a layout shift, which is why a slide-in animation built on `transform` costs nothing while the same animation built by animating `height` or `margin` costs plenty. This is the single most useful practical consequence of the definition. **Off-screen and never-visible content.** Shifts of content that was not visible in the viewport do not count. ## What CLS is not It is not a duration — there are no seconds in it — and it is not a pixel count. It is a **unitless score**, which is why the guidance is expressed as a bare number rather than a time budget. It is also not an initial-load-only metric: it keeps accruing for as long as the document is alive, so a shift triggered ten minutes into a session by a late-loading widget is still eligible. ## Where the shifts usually come from In practice a handful of patterns account for nearly all real CLS: - content injected **above** existing content after first paint — consent bars, promo strips, notification banners, A/B-test variants; - media and embeds whose box the browser could not know in advance, so the surrounding layout is laid out once without them and once with them; - content whose height changes when data arrives — a skeleton row replaced by a taller real row; - late style or webfont application that changes the size of text blocks. All of them are the same underlying mistake: the first layout the user saw was computed with information that later turned out to be wrong. ## Seeing it yourself Every individual shift is exposed to JavaScript as a `layout-shift` entry through `PerformanceObserver`, with a `value` (that shift's score), a `hadRecentInput` flag, and a `sources` array naming the nodes that moved and their before/after rectangles. That is the fastest way to move from "CLS is bad" to "this element moved 80 px at t = 2.1 s". ```js new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) console.log(entry.value, entry.sources); } }).observe({ type: 'layout-shift', buffered: true }); ``` ## The one-sentence version CLS answers "did the page move under the user's fingers?" — counting only movement of already-visible content that the user did not initiate, and only when the element's real layout position changed.
- A spinner is replaced by content of exactly the same size. Does that count as a layout shift?No. The spinner disappearing and the content appearing are not shifts in themselves — neither element had a previous position that changed — and because the boxes are the same size, nothing around them starts at a new position either. That is precisely why a well-sized skeleton placeholder is a CLS fix: the layout the user first sees is already the final one.
- Why is CLS reported as a bare number with no unit?Because each shift's score is the product of two viewport-relative fractions: how much of the viewport was affected and how far content moved as a share of the viewport's larger dimension. Both are ratios, so the product is unitless, and scores are comparable between a phone and a desktop monitor rather than being dominated by screen size.
- Does a shift that happens while the user is scrolling get the user-input exemption?No. The exemption is for discrete inputs — tap, click, key press — within 500 ms. Continuous interaction like scrolling is not treated as recent input, which is deliberate: content that loads in as you scroll and shoves the page around is exactly the experience CLS is meant to catch.
saying these in an interview costs you the question
- Says CLS is measured in seconds or milliseconds
- Claims any animation on the page hurts CLS
- Believes CLS only covers the initial page load
- Thinks an element appearing always counts as a shift
- Assumes shifts right after a user click still count