DEV Community

Linards Liepenieks
Linards Liepenieks

Posted on

Building Custom Scroll-Snap Sections: A Journey Through Mac Trackpad Hell ๐Ÿ–ฑ๏ธ๐Ÿ’€๐Ÿ”ฅ

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Result: Page has seizure. Moving on.

Attempt #2: "Debouncing will fix it!"

const debouncedScroll = debounce(() => {
  scrollToNextSection();
}, 300);
Enter fullscreen mode Exit fullscreen mode

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(/* ... */);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The momentum events have a signature:

  1. ๐Ÿƒ They fire rapidly (10-20ms apart)
  2. ๐Ÿ”„ They're all in the same direction
  3. ๐Ÿ“‰ 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
Enter fullscreen mode Exit fullscreen mode

Let me break down why each condition matters:

1. Time Gap Detection โฑ๏ธ

const timeSinceLastEvent = Date.now() - lastEventTime;
const isTimeTooQuick = timeSinceLastEvent < 1500;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Direction Consistency ๐Ÿงญ

const currentDirection = Math.sign(e.deltaY); // 1 or -1
const isSameDirection = currentDirection === lastDirection;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When two momentum events had the same delta (28 โ†’ 28), my check failed:

isDeltaDecreasing = (28 < 28) // false! โŒ
Enter fullscreen mode Exit fullscreen mode

The fix: Use <= instead of <:

const isDeltaNotIncreasing = currentDelta <= lastDelta; // true! โœ…
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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:', {/* ... */});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

2. useScrollThrottle

Manages adaptive throttling

const { isThrottled, updateThrottle } = useScrollThrottle({
  normalThrottle: 600,
  momentumThrottle: 1800,
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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 ๐Ÿ“š

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
Enter fullscreen mode Exit fullscreen mode

โญ 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.


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.

webdev #javascript #react #performance #ux

Top comments (0)