Skip to content

Field-only mobile CLS from scroll-driven bottom-sheet expand/collapse (Lighthouse reads 0.00)

TL;DR.

GSC/CrUX can report poor mobile CLS (>0.25) while Lighthouse and page-load lab probes read ~0.00. Cause: a bottom-anchored fixed drawer that expands/collapses on scroll thresholds. Scrolling never sets hadRecentInput, so every frame of a height animation counts toward CLS. Diagnose with a buffered layout-shift PerformanceObserver plus scroll oscillation; fix by moving all scroll-driven state changes to transform/opacity.

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. Tailwind transition-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)

  1. Emulate mobile + Slow 4G + 4x CPU (chrome-devtools MCP or Puppeteer).
  2. 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}).
  3. Load-only probe first (rules out font-swap/late-content). Then scroll oscillation across suspected UI thresholds (5x down/up, ~900ms apart). Sum value where !hadRecentInput; the sources attribution 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 + translateY driven by an IntersectionObserver on the in-flow panel. Zero layout changes on scroll.
  • Position FABs/floating panels with transform: translateY(var(--raise)) instead of animating bottom; compose hover scale into the same transform.
  • Tap-driven expand/collapse animations may stay: they land inside the 500ms hadRecentInput window 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.

No signals yet