Ever wanted your website to do that fancy section-snap thing where each scroll smoothly transitions to the next full section? Like those Apple-esque landing pages that feel all premium and smooth?
Here's my advice: Don't do it.
Seriously. It's overengineered, difficult to navigate for users, and breaks expected browser behavior. Users hate when you mess with their scrolling. Accessibility tools hate it. Mobile users really hate it. And you're about to spend way too much time fighting browser physics.
But if you're like me and ignored this advice anyway (or your designer really wants it, or it's actually justified for your specific use case), then buckle up. We're going deep.
The First Gotcha: It Needs a Container ๐ฆ
Before we even get to the momentum problem, here's something that'll bite you: you can't use default CSS scroll-snap on the page-level container.
You need a parent container with fixed height and overflow handling:
.scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.section {
height: 100vh;
scroll-snap-align: start;
}
Why? Because we need programmatic control over the scrolling behavior, and the window object doesn't give us the same level of control as a DOM element. Plus, we need ref access for our scroll detection logic.
This means restructuring your entire page. Already regretting this? Same.
The Problem That Wouldn't Quit ๐ค
"Just scrolling but no it needs to be a window."
That's what I wrote in my notes after the third day of fighting with what I thought would be a simple feature. Here's what I wanted: User scrolls โ page smoothly transitions to next section. Simple, right?
Narrator: It was not, in fact, a piece of cake.
Here's what I wanted: User scrolls โ page smoothly transitions to next section. Simple.
Here's what I got: User scrolls once โ page goes absolutely BONKERS and flies through 3-4 sections like it's trying to escape the viewport.
The culprit? Mac trackpad momentum scrolling. That beautiful, smooth inertia effect that makes scrolling feel so natural on macOS? Yeah, that same feature was absolutely destroying my custom scroll implementation.
When a Mac user swipes their trackpad, the browser doesn't just fire one scroll event. Oh no. It fires 40+ events over 2-3 seconds. Here's what my console looked like:
๐ฑ๏ธ Wheel event: {deltaY: 4} โ โ
Scroll to section 2
๐ฑ๏ธ Wheel event: {deltaY: 132} โ โ
Scroll to section 3
๐ฑ๏ธ Wheel event: {deltaY: 96} โ โ
Scroll to section 4
๐ฑ๏ธ Wheel event: {deltaY: 94} โ โ
Scroll to section 5
๐ฑ๏ธ Wheel event: {deltaY: 69} โ ๐ฅ WE'VE RUN OUT OF SECTIONS
... (35 more events that I'm desperately trying to ignore)
One. Single. Swipe.
The Naive Approaches (aka: Watch Me Fail Spectacularly) ๐คฆโโ๏ธ
Look, I know what you're thinking. "Just throttle it!" or "Use debounce!" Yeah, I tried that. And about five other things. Let me save you some time by showing you what doesn't work.
Attempt #1: "Just add an event listener!"
window.addEventListener('wheel', (e) => {
scrollToNextSection();
});
Result: Page has seizure. Moving on.
Attempt #2: "Debouncing will fix it!"
const debouncedScroll = debounce(() => {
scrollToNextSection();
}, 300);
Result: Events are already in the queue. Debouncing just delays the chaos. Still scrolls through multiple sections.
Attempt #3: "Maybe Intersection Observer?"
const observer = new IntersectionObserver(/* ... */);
Result: Better! But still doesn't prevent the momentum events from triggering new scrolls. The observer sees the section, but by then 30 more events have already fired.
Attempt #4: "Let me try FullPageJS"
Me: Imports 3rd party library
Also me: Realizes I still don't understand the problem
Result: Same issue. Because the problem isn't the solutionโit's my understanding.
Attempt #5: "What if I track cursor position?"
Me: Writes elaborate cursor tracking logic
Reality: Wheel events don't have cursor position changes.
Me: ๐คก
Attempt #6: "Throttling! That's the answer!"
let lastScroll = 0;
if (Date.now() - lastScroll < 900) return;
Result: Better! Page stops having seizures. But... 900ms feels random. Why 900ms? Why not 600ms? Or 1200ms? And sometimes momentum events still slip through.
At this point I realized I was just slapping bandaids on symptoms without understanding the actual problem. Classic overengineering moveโthrowing solutions at a problem I didn't fully understand yet.
I had console logs everywhere, a notebook full of timing diagrams, and a growing suspicion that I was fighting something fundamental about how trackpads work.
The Breakthrough: Understanding Inertia ๐
After copious amounts of coffee and StackOverflow diving, I found the answer buried in a 2010 thread:
"Since Mac spams scroll events with decreasing delta's to the browser every 20ms when inertial scrolling is enabled..."
Wait. Decreasing deltas?
That's when it clicked. Momentum scrolling isn't random chaosโit's a pattern. When you swipe a trackpad, the browser simulates physics:
User swipes trackpad
โ Initial event: deltaY = 132 (high momentum)
โ Next event (13ms later): deltaY = 96 (decaying)
โ Next event (14ms later): deltaY = 94 (still decaying)
โ Next event (15ms later): deltaY = 69 (continuing to decay)
... pattern continues for 2-3 seconds until deltaY โ 0
The momentum events have a signature:
- ๐ They fire rapidly (10-20ms apart)
- ๐ They're all in the same direction
- ๐ The delta values decrease over time (momentum decay)
And here's the kicker: The web doesn't have a native API to detect this.
I found out that native Mac apps get a momentumPhase property on scroll events. The web? We get deltaX, deltaY, and basically nothing else. No momentum detection. No inertia flag. Nada.
So here I am, overengineering a scroll experience, and the platform doesn't even give me the tools to do it properly. Beautiful. The entire web development community has been reverse-engineering momentum detection from first principles because the web platform said "figure it out yourself lol."
The Solution: Delta Pattern Analysis ๐ฌ
Armed with this knowledge, I built a momentum detector based on three conditions:
A scroll event is MOMENTUM (not intentional) if ALL of these are true:
const isTimeTooQuick = timeSinceLastEvent < 1500; // Less than 1.5s since last event
const isSameDirection = currentDirection === lastDirection; // Same scroll direction
const isDeltaNotIncreasing = currentDelta <= lastDelta; // Delta not getting bigger
Let me break down why each condition matters:
1. Time Gap Detection โฑ๏ธ
const timeSinceLastEvent = Date.now() - lastEventTime;
const isTimeTooQuick = timeSinceLastEvent < 1500;
If events are firing less than 1.5 seconds apart, they're likely part of the same momentum cascade. A human user won't intentionally scroll again that quicklyโthey're waiting for the animation to finish.
Real log showing momentum events:
timeSinceLastEvent: 13ms โ TOO FAST, must be momentum
timeSinceLastEvent: 14ms โ Still too fast
timeSinceLastEvent: 15ms โ Definitely momentum
timeSinceLastEvent: 3547ms โ Enough time passed, could be intentional
2. Direction Consistency ๐งญ
const currentDirection = Math.sign(e.deltaY); // 1 or -1
const isSameDirection = currentDirection === lastDirection;
Momentum events continue in the same direction. If the user suddenly scrolls the opposite way, that's intentionalโthey're fighting the momentum to change direction.
3. Delta Decay Pattern ๐
const currentDelta = Math.abs(e.deltaY);
const isDeltaNotIncreasing = currentDelta <= lastDelta;
This is the most important indicator. Momentum naturally decays due to physics simulation. If the delta is increasing, the user is applying more forceโthat's intentional.
Here's a real momentum cascade from my logs:
deltaY: 4 โ User's initial swipe (intentional) โ
deltaY: 132 โ Momentum starts strong ๐
deltaY: 96 โ Decaying โฌ๏ธ
deltaY: 94 โ Still decaying โฌ๏ธ
deltaY: 69 โ Continuing to decay โฌ๏ธ
deltaY: 57 โ Getting smaller โฌ๏ธ
deltaY: 53 โ Even smaller โฌ๏ธ
deltaY: 49 โ Approaching zero โฌ๏ธ
deltaY: 45 โ Almost done โฌ๏ธ
... continues for another 30+ events
See the pattern? After the user's initial intentional scroll (deltaY: 4), the momentum kicks in with a burst (132) and then steadily decreases. That decreasing pattern is the fingerprint of inertia.
The Complete Detection Logic
export const useTouchpadDetection = ({
minTimeGap = 1500,
minDelta = 4,
}: UseTouchpadDetectionProps = {}) => {
const lastDeltaRef = useRef(0);
const lastDirectionRef = useRef(0);
const lastEventTimeRef = useRef(0);
const detectInertia = (e: WheelEvent): boolean => {
const now = Date.now();
const currentDelta = Math.abs(e.deltaY);
const currentDirection = Math.sign(e.deltaY);
const timeSinceLastEvent = now - lastEventTimeRef.current;
// Check all three conditions
const isTimeTooQuick = timeSinceLastEvent < minTimeGap;
const isSameDirection =
lastDirectionRef.current !== 0 &&
currentDirection === lastDirectionRef.current;
const isDeltaNotIncreasing = currentDelta <= lastDeltaRef.current;
const isMomentumScroll =
isTimeTooQuick &&
isSameDirection &&
isDeltaNotIncreasing;
// Update tracking for next event
lastDeltaRef.current = currentDelta;
lastDirectionRef.current = currentDirection;
lastEventTimeRef.current = now;
return isMomentumScroll;
};
return { detectInertia };
};
Adaptive Throttling: The Final Piece ๐ฏ
Once we can detect momentum, we can apply adaptive throttling:
const NORMAL_THROTTLE = 600; // Intentional scrolls - be responsive
const MOMENTUM_THROTTLE = 1800; // Momentum scrolls - block everything
const effectiveThrottle = isMomentumScroll
? MOMENTUM_THROTTLE
: NORMAL_THROTTLE;
Why two different values?
Normal throttle (600ms): When a user intentionally scrolls, we want to be responsive. 600ms is enough to prevent accidental double-scrolls but still feels snappy.
Momentum throttle (1800ms): When we detect momentum, we need to block events for the entire duration of the momentum cascade (typically 2-3 seconds). 1800ms covers most of this.
Here's the flow:
User swipes trackpad
โ
Event 1: deltaY = 4 (intentional)
โ โ
Scroll to next section
โ ๐ Set 600ms throttle
โ
Event 2: deltaY = 132 (13ms later - MOMENTUM!)
โ โ Blocked by throttle
โ ๐ Extend to 1800ms throttle
โ
Events 3-40: All momentum
โ โ All blocked by 1800ms throttle
โ
1800ms passes, momentum events stop
โ
User scrolls again
โ โ
Accepted (enough time has passed)
The Edge Cases That Almost Broke Everything ๐
Bug #1: Equal Consecutive Deltas
My first version checked currentDelta < lastDelta. But look what happened:
deltaY: 28
deltaY: 28 โ SAME VALUE!
deltaY: 24
When two momentum events had the same delta (28 โ 28), my check failed:
isDeltaDecreasing = (28 < 28) // false! โ
The fix: Use <= instead of <:
const isDeltaNotIncreasing = currentDelta <= lastDelta; // true! โ
Bug #2: Console Logs Were Slowing Everything Down
I had debug logs everywhere:
console.log('๐ฑ๏ธ Wheel event:', {
deltaY: e.deltaY,
timeSinceLastEvent,
isTimeTooQuick,
isSameDirection,
// ... 10 more properties
});
Each console.log adds I/O overhead. With 40+ events per scroll, this was adding significant latency. The solution:
const DEBUG = false; // Toggle for development
DEBUG && console.log('๐ฑ๏ธ Wheel event:', {/* ... */});
Production performance improved dramatically just by removing console logs.
Bug #3: First Event After Direction Change
When a user scrolled down, then up, the first "up" event would fail detection because:
const isSameDirection = currentDirection === lastDirectionRef.current;
// -1 === 1 โ false
This is actually correct behavior! A direction change indicates intentional user input. But it taught me that edge cases in momentum detection are features, not bugs.
The Final Architecture ๐๏ธ
I split the logic into three composable hooks:
1. useTouchpadDetection
Detects momentum using delta pattern analysis
const { detectInertia, isValidDelta } = useTouchpadDetection({
minTimeGap: 1500,
minDelta: 4,
});
2. useScrollThrottle
Manages adaptive throttling
const { isThrottled, updateThrottle } = useScrollThrottle({
normalThrottle: 600,
momentumThrottle: 1800,
});
3. useScrollContainer
Orchestrates everything
const throttledWheelHandler = (e: WheelEvent) => {
e.preventDefault();
const isMomentumScroll = detectInertia(e);
if (!isValidDelta(e.deltaY)) return;
if (isScrollingRef.current) return;
if (isThrottled(isMomentumScroll)) return;
const direction = e.deltaY > 0 ? 1 : -1;
const nextSection = currentSection + direction;
if (nextSection >= 0 && nextSection < totalSections) {
scrollToSection(nextSection);
}
};
Clean, testable, and composable.
What I Learned (The Hard Way) ๐
Let me be real with you for a second.
1. Seriously, don't do custom scroll-snapping unless you really need to
I spent a week on this. A week. For a feature that breaks browser expectations and annoys users. Was it worth it? Debatable. Did I learn a ton? Absolutely. Would I do it again? Only if someone's paying me well or there's a really good UX reason.
2. The web platform has surprising gaps
Native apps get momentumPhase. Web developers get to reverse-engineer physics. This is kind of insane considering how fundamental scrolling is to web interaction. But hey, at least we get to feel smart when we solve it, right?
3. Console logs aren't free
In high-frequency events like scrolling, logging overhead is real. I was wondering why my throttling felt sluggishโturns out 40+ console.logs per scroll adds up. Always gate debug logs behind a flag in production.
4. Pattern recognition beats magic numbers
My first instinct was "throttle everything for 900ms!" But understanding the why behind momentum patterns led to a way better solution with adaptive throttling. Still overengineered? Maybe. But at least it's informed overengineering.
5. Physics leaves fingerprints
The decreasing delta pattern isn't arbitraryโit's a simulation of real-world physics. Once I understood that, the solution became obvious. Well, obvious in hindsight after days of confusion.
6. Sometimes the simple stuff is the hardest
"Just scroll to the next section" turned into a week-long journey through browser APIs, momentum physics, and performance optimization. Never underestimate the fundamentals. They'll humble you real quick.
7. Professional libraries use the same approach
After building all this, I found wheel-gestures uses nearly identical logic. So either I'm as smart as library authors, or we're all equally overengineering the same problem. Probably the latter.
The Results ๐
After implementing delta pattern analysis with adaptive throttling:
- โ Smooth, intentional scrolling on all devices
- โ Zero accidental section skips on Mac trackpads
- โ Responsive feel (600ms) for intentional scrolls
- โ Complete momentum blocking (1800ms) when detected
- โ Works with touch events, mouse wheels, and trackpads
- โ Clean, composable architecture
The page finally scrolls exactly as intended. One swipe, one section. Every time.
Resources ๐
- StackOverflow: Detecting inertial scrolling
- wheel-gestures library - Professional implementation
- My full implementation on GitHub (add your link)
- Apple's Handling Trackpad Events - How native apps do it
Final Thoughts ๐ญ
If you're building custom scroll interactions, momentum detection isn't optionalโit's essential. The web doesn't give you the tools out of the box, so you gotta build them yourself.
The good news? Once you understand the pattern (fast events + same direction + decreasing deltas = momentum), the solution is pretty straightforward.
The bad news? You're probably going to spend a few days discovering this the hard way, just like I did. And then you'll question whether the whole thing was worth it in the first place. (It probably wasn't, but at least you learned something, right?)
But hey, at least now you know why that random 900ms throttle everyone suggests feels so arbitrary. It wasn't the solutionโit was just a bandaid on the real problem.
๐ฆ Get the Code
Ready to use this in your project?
๐ฎ Try it Live
Interactive Demo - Experience the smooth scrolling with your own trackpad
๐พ Download Options
Option 1: GitHub (Free, instant access)
git clone https://github.com/LinardsLiepenieks/react-scroll-snap-momentum.git
โญ Star the repo
Option 2: Complete Package (Free, get notified of updates)
๐ฆ Download on Gumroad
The Gumroad package includes:
- โ All 4 hooks with TypeScript
- โ 2 working examples (basic + routing)
- โ Full documentation
- โ Free 20-minute consultation to help you implement it
Both options are 100% free. Gumroad just adds you to the updates list so you'll know when I add new features!
๐ฌ Let's Connect
Need help implementing this? I offer a free 20-minute consultation to get you started.
- ๐ผ LinkedIn: Linards Liepenieks
- ๐ง Email: linardsliepenieks@gmail.com
- ๐ Issues/Questions: GitHub Issues
Have you fought with momentum scrolling? Found a different approach? Or did you wisely decide not to mess with browser defaults? Drop a commentโI'd love to hear about your experience (or your decision to avoid this entire mess).
And if this saved you a few days of debugging, consider sharing it with someone who's about to learn about Mac trackpad momentum the hard way. Misery loves company. ๐
P.S. - If your PM asks for scroll-snapping, show them this article first. Maybe they'll reconsider. Probably not. But at least you tried.
Top comments (0)