A tiktok-style one-vine-per-gesture feed (scroll-snap-type: y mandatory + scroll-snap-stop: always, plus a JS wheel hijack) worked on Chrome, Firefox, mobile Chrome, mobile Brave and mobile Safari — and scrolled terribly on desktop Safari. It turned into three separate defects. The middle one is the interesting one, and it eventually bit Chrome too.
Companion to [[Firefox discards out-of-range scroll-snap positions that Chrome clamps — a zero-offset snap sentinel under scroll-padding-top is unreachable in Gecko]] (snap rest points out of range in Gecko) from the same codebase.
The architecture that creates the problem
Desktop is the only platform that takes this path:
onWheelcallspreventDefault()on every wheel event (window,passive: false) — native wheel scrolling never happens.- Accumulated intent >= 80px flips exactly one card via a programmatic scroll.
- A latch (
wheelSpent) swallows the rest of the gesture so the momentum tail cannot flip more cards. A 150ms quiet gap or a direction reversal starts a new gesture.
Touch never hits this (native snap handles flings), which is why mobile Safari was always fine.
Defect 1 — WebKit kills programmatic smooth scrolls inside a mandatory-snap container
scrollTo({behavior:"smooth"}) / scrollIntoView({behavior:"smooth"}) inside a scroll-snap-type: y mandatory scroller: WebKit's snap logic retargets immediately instead of letting the animation finish (webkit#203968). Blink and Gecko complete the animation first.
Fix — own the animation. A rAF loop writing scrollTop per frame, with mandatory snap suspended for the flight:
function animateScrollTo(top) {
cancelScrollAnim();
const max = scroller.scrollHeight - scroller.clientHeight;
const to = Math.round(Math.min(max, Math.max(0, top)));
const from = scroller.scrollTop, delta = to - from;
if (Math.abs(delta) < 2) { scroller.scrollTop = to; return; }
document.documentElement.classList.add("snap-pause"); // scroll-snap-type: none
const dur = Math.min(480, 240 + 0.2 * Math.abs(delta));
const t0 = performance.now();
const step = () => {
const x = Math.min(1, (performance.now() - t0) / dur);
const e = x < 0.5 ? 4*x*x*x : 1 - Math.pow(-2*x + 2, 3) / 2;
scroller.scrollTop = from + delta * e;
if (x < 1) { raf = requestAnimationFrame(step); return; }
scroller.scrollTop = to; // exact snap rest point: re-enabling is a no-op
cancelScrollAnim(); // removes snap-pause
};
raf = requestAnimationFrame(step);
}Two things make suspending snap safe: the landing point is computed as the exact rest point mandatory snap would pick (cardTop - scrollPaddingTop), so re-enabling moves nothing; and the animation is cancelled wherever anything else takes the scroll (teardown, overlay open, feed rebuild, pointerdown) so the class can never leak.
Use it on every engine rather than behind a UA sniff — it removes the dependency on engine smooth+snap interop instead of betting on one engine's failure mode.
Caveat worth stating: headless WebKit (Playwright) never reproduced this. It completes the smooth scroll cleanly. The mechanism is documented, not observed in automation — the fix is justified by removing the dependency, not by a caught repro.
Defect 2 — the momentum tail has no phase flag, so the gesture latch never clears
This is the one that produced the user-visible symptom, and the report is diagnostic on its own:
"the first scroll works, then i have to click on the background for the next scroll to work. otherwise i have to make a really dramatic/slow scrolling gesture."
macOS keeps delivering momentum wheel events for well over a second after the fingers lift. preventDefault() does not stop the tail. And there is no phase information exposed to JS — no equivalent of AppKit's NSEvent.phase / momentumPhase. So:
- The 150ms quiet gap never arrives — the tail keeps
lastWheelAtfresh continuously. wheelSpentstays latched, and every subsequent flick is classified as more of the spent gesture.- A new flick during the tail generates its own tail, so the stream never breaks. Permanently stuck.
Both workarounds in the report confirm it: clicking cancels momentum delivery (that is what finally opens the gap), and a slow exaggerated gesture has its own events far enough apart to read as separate gestures.
Fix: read the tail's shape, since you cannot read its phase
A momentum tail cannot gain energy — it decays monotonically once the fingers are gone. So a delta that jumps back up is a new flick landing on top of it.
const WHEEL_REFLICK = 1.8; // delta must exceed this multiple of the last one
const WHEEL_REFLICK_MIN = 12; // px floor: end-of-tail jitter must not trip it
const WHEEL_REFLICK_DELAY = 200; // ms after a flip before a rise counts
const abs = Math.abs(delta);
const reflick =
wheelSpent &&
now - wheelFlipAt > WHEEL_REFLICK_DELAY &&
abs > wheelLast * WHEEL_REFLICK &&
abs > WHEEL_REFLICK_MIN;
if (now - wheelAt > GESTURE_GAP || reversed || reflick) { wheelAcc = 0; wheelSpent = false; }
wheelAt = now;
wheelLast = abs; // update every event, before the spent check
if (wheelSpent) return;
wheelAcc += delta;
if (Math.abs(wheelAcc) >= WHEEL_STEP) {
navigate(Math.sign(wheelAcc)); wheelSpent = true; wheelFlipAt = now;
}Four properties keep a heuristic like this cheap to be wrong about:
- Ratio, not absolute threshold. During decay the ratio sits ~0.92; a fresh full-strength flick against a decayed tail is 5-10x. Huge margin.
- Absolute floor. At the end of a tail deltas are 1-5px and ratios get noisy. The floor makes that region inert.
- Hold-off after a flip. Within ~200ms your fingers may still be on the glass driving the same gesture harder — deltas legitimately rise there. Ignoring that window prevents one flick advancing two cards.
- Unlatching is not flipping. Clearing the latch only resets the accumulator;
WHEEL_STEPof fresh intent must still accumulate. A spurious unlatch mid-tail costs nothing because the remaining tail energy rarely reaches the step.
The rising edge of a first gesture would also trip the ratio test — which is fine, because the check only runs while wheelSpent is true.
Defect 3 — the same latch on Blink, and why you cannot just delete the hijack
Safari got fixed, and Chrome then showed the identical symptom. The latch jam was never Safari-specific — macOS trackpad momentum runs long on Blink too. (My probe had been reporting the swallow on Chromium the whole time; I dismissed it as "never triggers in practice". It does.)
The tempting fix is to delete the hijack everywhere and let native snap do the work. Measure before you do that. With the hijack disabled, trusted wheel events (page.mouse.wheel), ~800px cards, scroll-snap-type: y mandatory + scroll-snap-stop: always:
| 4 x 300px gestures | one 3000px fling | single 100px notch | |
|---|---|---|---|
| Chromium | 0 0 0 0 — never moves | 4.12 cards — skates past | 0 — sticks |
| WebKit | 1.05, 2.05, 3.05, 4.05 — one card each | 4.05 cards | 1.05 cards |
WebKit treats each discrete wheel gesture as one snap step, even a single small notch. Blink applies wheel deltas straight to the scroll offset: a 300px scroll on an 800px card does not reach the midpoint so mandatory snap pulls it back (feels dead), while a big fling crosses several snap areas at once — scroll-snap-stop: always does not cap Blink's wheel scrolling the way it caps a touch fling.
So the split is load-bearing, not laziness:
- macOS Safari: do not bind the wheel listener at all. The engine has the phase information you do not, and native snap already caps a fling at one card — exactly how mobile Safari always worked.
- Blink: keep the hijack, fix the latch.
Do not bind a no-op handler on the native path — a non-passive window wheel listener makes WebKit block scrolling on JS for a handler that only ever returns. Skip registration entirely:
const nativeWheel =
/Mac/.test(navigator.userAgent) &&
/AppleWebKit/.test(navigator.userAgent) &&
__omp_shell("/Chrome|Chromium|Edg\\//.test(navigator.userAgent) &&")
__omp_shell("/OPR\\//.test(navigator.userAgent);")
if (!nativeWheel) window.addEventListener("wheel", onWheel, { passive: false });Instrumentation: four ways the probe lied to me
More of the elapsed time went into fixing the measurement than the code. Every one of these produced a confident wrong number, which is worse than an error.
1. Untrusted events cannot measure a native scroll path. window.dispatchEvent(new WheelEvent(...)) runs your handler but never scrolls anything — no default action for untrusted events. So it can test a hijack, and is structurally blind to native scrolling. Once part of your platform matrix goes native you need trusted input (page.mouse.wheel, CDP Input.dispatchMouseEvent). Useful inversion: on the native path a synthetic probe reading 0px is a positive signal that the listener really is unbound.
2. One page.evaluate per event lets round-trip latency define your gesture boundaries. The whole state machine keys on inter-event spacing of 25-150ms; a Playwright round trip is comparable. I got 4-vs-12-card noise from identical input. Dispatch the whole schedule inside the page with real timers, and have it return actual delivery timestamps so you can verify the spacing you asked for is the spacing you got:
const times = await page.evaluate((steps) => new Promise((done) => {
const t = []; let i = 0;
const fire = () => {
if (i >= steps.length) return done(t);
const [deltaY, gap] = steps[i++];
t.push(Math.round(performance.now()));
window.dispatchEvent(new WheelEvent("wheel", { deltaY, cancelable: true, bubbles: true }));
setTimeout(fire, gap);
};
fire();
}), plan);
// if max consecutive gap > GESTURE_GAP, the "momentum tail" was not one — report inconclusive3. Deriving a unit from one sample. I reported movement in cards as px / firstCard.height. A card whose media had not resolved collapses to ~57px, turning a plain one-card scroll into "14.08 cards" — a fake bug I then chased. Use the median spacing between consecutive item tops, never one element's height.
4. A degraded local fixture can fake a code bug. Against a local static preview the videos 404. Three consequences: cards get a broken class; the "which card is active" logic skips them so the active index cannot advance (so separated gestures re-target the card they are already on, while chained ones work fine); and a dense 43-event burst makes the mount/prune machinery churn until the scroll runs away — 7-14 cards for what should be one.
The move that saved me: I checked out master and reproduced the runaway there too. Same fixture, unmodified code, same 7-14 cards. That immediately reclassified it from "my regression" to "the fixture", and it is the cheapest possible experiment. Now the probe refuses to render a verdict when preconditions are not met:
[chromium] C flick + tail + 2nd flick -> 10761px = 14.01 cards INCONCLUSIVE
(media 9/20; the feed churns under a dense burst and the scroll
runs away regardless of wheel handling - run against a real deploy)Test the heuristic from both sides
A momentum-detection heuristic has two failure modes, and a suite that only checks one is worse than useless — it will happily bless a detector that fires on every jitter spike:
- Probe C: one flick, then a decaying tail with a real second flick buried in it → must advance 2 cards.
- Probe E: one flick, then the same tail with jitter and no second flick → must advance exactly 1.
Measured on a real deploy: C went 1.05 → 2.05 cards with the fix, E held at 1.05. A detector that passes C by firing on jitter fails E. Also place C's injected flick with care — mine originally landed outside the app's own 600ms nav-chaining window and reported a swallow that was really the pinned-active-index fixture artifact.
Rules of thumb
- You cannot own gesture boundaries from JS on a macOS trackpad. There is no phase flag. If the platform's native snap does the job, give the gesture back to the engine — but verify per engine, because Blink and WebKit behave completely differently under identical snap CSS.
scroll-snap-stop: alwaysdoes not cap a Blink wheel gesture the way it caps a touch fling.- Never combine
behavior: "smooth"with mandatory snap. Own the animation and suspend snap for its duration, landing on an exact rest point. - A momentum tail is identifiable by shape, not by flag: monotonic decay, and it cannot gain energy.
- When a harness and a user disagree, the harness is usually the one that is wrong — and the fastest way to find out is to run the same measurement against unmodified upstream code.