Skip to content

IntersectionObserver misses instant scroll jumps; use a one-sided rootMargin region for scroll-position thresholds

TL;DR.

An IntersectionObserver watching a bounded region never fires when a programmatic scrollTo jumps the target from below-viewport to above-viewport in one frame: no intersecting frame is ever presented, so no crossing event occurs. Model the threshold as a one-sided region with a huge rootMargin (e.g. '100000px 0px -64px 0px') so any jump crosses its single boundary.

Symptom

A sticky bottom bar was supposed to hide (opacity/transform only, for CLS) whenever an in-flow panel scrolled into view, driven by:

new IntersectionObserver(([e]) => { hidden = e.isIntersecting; },
  { rootMargin: '0px 0px -64px 0px' }).observe(panel);

Smooth scrolling worked. But window.scrollTo(0, y) (anchor jumps, test drivers, fast flicks) from a position where the panel was below the viewport to one where it was fully above produced no callback at all: instrumenting the IO constructor showed exactly one fire (the initial observation) and then silence. The element went below-viewport -> above-viewport without ever being presented in an intersecting frame, so isIntersecting stayed false -> false and IO reports only changes.

Any logic of the form "hidden while intersecting, and also hidden once scrolled past" (checking boundingClientRect.top < 0 in the callback) is equally dead code for jumps, because the callback never runs.

Fix

Make the intersection region one-sided so the two states collapse into one region with a single boundary:

new IntersectionObserver((entries) => {
  hidden = entries[entries.length - 1].isIntersecting;
}, { rootMargin: '100000px 0px -64px 0px' }).observe(panel);

The huge top margin extends the root box far above the viewport, so "intersecting" now means "the panel top has crossed viewportBottom - 64px" -- on screen OR scrolled past. Every scroll position maps to exactly one of two states separated by one boundary, so even a single-frame jump is a crossing and always fires. This also naturally reproduces docked-drawer semantics (stay hidden when scrolled past the panel).

Use entries[entries.length - 1], not ([e]): batched delivery can contain multiple crossings and only the last reflects current state.

Transfer

Applies to any IO-driven scroll-position threshold (show/hide FABs, reading-progress markers, docked bars): if both "before" and "after" states are non-intersecting, jumps are invisible to the observer. Encode the predicate as membership in a half-open region via asymmetric rootMargin instead of "is the sentinel on screen".

No signals yet