Symptom
Google Search Console flags 'CLS issue: more than 0.25 (mobile)' on a URL group (field/CrUX data), but every Lighthouse run and every page-load lab probe reports CLS ~0.00-0.04. Desktop is green.
Why lab misses it
CrUX CLS is measured over the full page lifetime, including shifts while the user scrolls. hadRecentInput (the CLS exclusion) is only set for ~500ms after taps/keys — scrolling never sets it. So any UI that changes layout geometry in response to scroll position accumulates CLS that no navigation-time lab audit sees.
The pattern that caused 0.43 field CLS
A mobile results drawer, position: fixed and bottom-anchored, that 'docks' into the page flow and slide-expands (Svelte transition:slide, i.e. height animation) when the user scrolls near the page bottom, and collapses when they scroll away:
- Height animation of a bottom-anchored fixed element moves its top edge every frame -> one layout-shift entry per frame.
- When docked (in-flow), the expansion pushes all content below (related cards, footer) -> large shifts.
- A floating action button positioned via animated
bottom(a layout property, e.g. Tailwindtransition-all+style:bottom) rides along, adding entries. - Measured: ~0.07 CLS per threshold crossing; 5 scroll oscillations = 0.363. Users hovering around a calculator's results area easily hit 0.4+.
Note: de-animating does NOT fix it — CLS sums impact x distance per frame, so one instant jump scores about the same as a 200ms animation. Only transform/opacity/clip-path changes are shift-free.
Diagnosis recipe (works on prod, no build access)
- Emulate mobile + Slow 4G + 4x CPU (chrome-devtools MCP or Puppeteer).
- Inject:
new PerformanceObserver(l => {for (const e of l.getEntries()) window.__shifts.push({value: e.value, hadRecentInput: e.hadRecentInput, sources: e.sources.map(s => s.node?.tagName + '.' + s.node?.className)})}).observe({type:'layout-shift', buffered:true}). - Load-only probe first (rules out font-swap/late-content). Then scroll oscillation across suspected UI thresholds (5x down/up, ~900ms apart). Sum
valuewhere!hadRecentInput; thesourcesattribution names the culprit nodes directly.
Fix pattern
- Render the expanded panel in-flow, always (SSR'd, geometry never changes on scroll).
- The floating collapsed bar is a separate always-mounted fixed element, hidden/shown via
opacity+translateYdriven by an IntersectionObserver on the in-flow panel. Zero layout changes on scroll. - Position FABs/floating panels with
transform: translateY(var(--raise))instead of animatingbottom; compose hover scale into the same transform. - Tap-driven expand/collapse animations may stay: they land inside the 500ms
hadRecentInputwindow and are excluded.
Bonus gotcha
If field CWV URL counts jump from 0 to N overnight (both good and poor), suspect a traffic-composition change (e.g. ad campaigns) giving pages CrUX sample volume for the first time — the defect is usually old, only the measurement is new.