How a single MacBook trackpad turned my “smooth” calendar into a time‑machine.
TL;DR
Trackpads don’t emit smooth, predictable scroll deltas. A single swipe often starts with sharp spikes (e.g. …17, 67, 82…) and transitions into a decelerating phase. But just when it seems to be tapering off—boom—a rogue value can pop up in a sequence such as …17, 15, 13, 26, 12, 11…, throwing off gesture detection. A reliable fix is a two-phase filter: first, ignore all events for ~100ms after a gesture begins; then, during the deceleration phase, compare the last three deltas—not just the current and previous—to ensure values are consistently tapering. This approach prevents unintended jumps.
The Bug: From Smooth to Jumpy
I built a calendar where swiping up/down flips between months. Mouse‑wheel testing felt flawless. Trackpad testing, however, leapt multiple months per swipe.
// Naïve first pass
const handleWheel = (e) => {
if (e.deltaY < 0) navigateUp();
else if (e.deltaY > 0) navigateDown();
};
It turns out a trackpad swipe is many events—some intentional, most momentum.
Attempt 0: Throttle the Scroll Events
Before diving into inertia detection, my first hypothesis was to simply add a delay between navigations—a throttle that would prevent another scroll for, say, 300–500ms after the first. While this helped reduce multiple triggers on a mouse, it failed on trackpads. Why?
Because inertia events can last well over 1000ms. Using such a large throttle would make quick, intentional swipes between months feel sluggish and unresponsive.
That led to deeper investigation—and more precise filtering based on deltaY patterns.
Attempt 1: 100 ms Throttle + Basic Inertia Check
My next idea was to keep the throttle but shorten it to 100 ms—just enough to swallow the initial burst—then decide if an event was momentum by checking whether the change between consecutive deltaY values was small (≤ 2).
let lastNav = 0;
let prevDelta = 0;
const THROTTLE_MS = 100;
const TOLERANCE = 2;
const handleWheel = (e) => {
const now = Date.now();
if (now - lastNav < THROTTLE_MS) return; // swallow initial burst
// Treat as inertia if the magnitude is shrinking toward zero
const isDecelerating =
(e.deltaY > 0 && e.deltaY - prevDelta < TOLERANCE) || // scrolling down: values getting smaller
(e.deltaY < 0 && e.deltaY - prevDelta> TOLERANCE); // scrolling up: values getting closer to zero
if (isDecelerating) {
prevDelta = e.deltaY;
return; // skip momentum event
}
// Real navigation
if (e.deltaY < 0) navigateUp();
else if (e.deltaY > 0) navigateDown();
prevDelta = e.deltaY;
lastNav = now;
};
Logging shattered that illusion:
20, 19, 18, 17, 15, 13, 26, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 2, 1
A Rogue Appears
That “26” was the real problem. Everything looked like a natural deceleration—until suddenly, 26 landed out of nowhere, just before values dropped back to 12, 11, 10... It wasn’t part of the initial spike, nor the tapering tail. It showed up late in the sequence, far enough from the gesture start that my 100 ms throttle missed it, and different enough from the previous deltas that my “small diff” logic failed too.
It became clear: trackpad momentum isn’t just noisy—it’s chaotic. A late, large delta can still be part of the gesture’s inertia, and it can sneak through.
Final Fix: Triple Comparison + Timing Window
To handle both early chaos and late rogue spikes, I moved to a two-phase system:
Post-gesture window (~100 ms): treat everything as momentum.
Deceleration phase: compare the current deltaY with the two previous values. If any comparison shows a slow progression toward zero, treat it as inertia and skip it.
But before even running delta comparisons, the logic includes two important timing checks:
if (timeSinceLastEvent >= INERTIA_DETECTION_TIME_MS) return false;
if (timeSinceLastGestureEvent <= POST_GESTURE_INERTIA_WINDOW_MS) return true;
The first check ensures we're not looking at an isolated event: if it's been more than 70ms since the last wheel event, it’s not part of momentum. We treat it as a fresh gesture and let it through.
The second check is our post-gesture inertia window: for the first 100ms after a real user scroll, we treat all events as momentum. This catches the noisy burst that typically follows a trackpad swipe.
Here’s the final React hook:
import {type RefObject, useCallback, useEffect, useRef} from 'react';
const INERTIA_DETECTION_TIME_MS = 70;
const POST_GESTURE_INERTIA_WINDOW_MS = 100;
const INERTIA_DELTA_TOLERANCE = 2;
export const useWheelNavigation = (
containerRef: RefObject<HTMLDivElement>,
onScrollUp: () => void,
onScrollDown: () => void
) => {
const lastWheelEventTime = useRef(0);
const lastGestureEventTime = useRef(0);
const previousWheelDeltaY = useRef(0);
const olderWheelDeltaY = useRef(0);
const hasInertialPattern = (
current: number,
previous: number,
older: number,
timeSinceLastEvent: number,
timeSinceLastGestureEvent: number
) => {
if (timeSinceLastEvent >= INERTIA_DETECTION_TIME_MS) return false;
if (timeSinceLastGestureEvent <= POST_GESTURE_INERTIA_WINDOW_MS) return true;
const recent = current - previous;
const olderDiff = previous - older;
const overall = current - older;
return current >= 0
? recent <= INERTIA_DELTA_TOLERANCE ||
olderDiff <= INERTIA_DELTA_TOLERANCE ||
overall <= INERTIA_DELTA_TOLERANCE
: recent >= -INERTIA_DELTA_TOLERANCE ||
olderDiff >= -INERTIA_DELTA_TOLERANCE ||
overall >= -INERTIA_DELTA_TOLERANCE;
};
const shouldSkipWheelEvent = (event: WheelEvent): boolean => {
const now = Date.now();
const timeSinceLastEvent = now - lastWheelEventTime.current;
const timeSinceLastGestureEvent = now - lastGestureEventTime.current;
const skip = hasInertialPattern(
event.deltaY,
previousWheelDeltaY.current,
olderWheelDeltaY.current,
timeSinceLastEvent,
timeSinceLastGestureEvent
);
lastWheelEventTime.current = now;
olderWheelDeltaY.current = previousWheelDeltaY.current;
previousWheelDeltaY.current = event.deltaY;
if (!skip) lastGestureEventTime.current = now;
return skip;
};
const handleWheel = useCallback((event: WheelEvent) => {
event.preventDefault();
if (shouldSkipWheelEvent(event)) return;
event.deltaY < 0 ? onScrollUp() : onScrollDown();
}, [onScrollUp, onScrollDown]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [handleWheel]);
};
Conclusion
Trackpad gestures are messy—noisy at the start, unpredictable in the middle, and sometimes sneaky at the end. A naive scroll handler won’t cut it. But with just a bit of timing logic and a triple-delta comparison, you can tame the chaos and deliver a smooth, reliable scroll experience that feels intentional every time.
Top comments (0)