ehsan.blog
~/blog/how-to-debounce-wheel-scrolling-with-a-lock — zsh
cat how-to-debounce-wheel-scrolling-with-a-lock.md

How to debounce wheel scrolling so one flick advances one panel

·4 min read

My portfolio moves sideways one full-screen panel at a time when you scroll — the horizontal-scroll slideshow this builds on. The first version had a nasty bug: a single flick of the wheel would fly past three or four panels at once. The reason is that a mouse wheel doesn’t fire one event per flick — it fires a burst of them. This post shows how I fixed it with a lock and a timer so one gesture equals exactly one panel.

Why one flick fires many events

When you spin the wheel, the browser dispatches a stream of wheel events — easily 10 to 30 of them for a single physical flick, especially on trackpads with momentum. If every event advances a panel, you overshoot instantly.

What we want is: react to the first event, then ignore the rest until the burst is clearly over. That pattern is called debouncing, and here it’s implemented as a simple lock.

The lock is a ref, not state

I keep a boolean flag that says “an animation is in progress, ignore wheel events”. Crucially it’s a useRef, not useState:

tsx
// src/App.tsx
const isScrolling = useRef(false)

Why a ref? Two reasons. State updates are asynchronous — if I set state at the top of the handler, the very next wheel event microseconds later would still read the old value, and the lock wouldn’t work. A ref’s .current updates synchronously and is readable immediately. Second, flipping this flag should never trigger a re-render; it’s internal bookkeeping, not something the UI displays.

The handler

Here’s the full wheel listener:

tsx
// src/App.tsx
useEffect(() => {
  if (isMobile) return
  const onWheel = (e: WheelEvent) => {
    e.preventDefault()
    if (isScrolling.current) return // locked? ignore this event
    isScrolling.current = true // lock immediately

    const dir = e.deltaY > 0 || e.deltaX > 0 ? 1 : -1
    setCurrent((c) => {
      const next = Math.max(0, Math.min(totalSections - 1, c + dir))
      setProgress((next / (totalSections - 1)) * 100)
      if (trackRef.current) {
        trackRef.current.style.transform = `translateX(-${next * 100}vw)`
        trackRef.current.style.transition =
          "transform 0.8s cubic-bezier(0.77, 0, 0.175, 1)"
      }
      return next
    })

    setTimeout(() => {
      isScrolling.current = false // unlock after the burst + animation
    }, 900)
  }
  window.addEventListener("wheel", onWheel, { passive: false })
  return () => window.removeEventListener("wheel", onWheel)
}, [isMobile, totalSections])

Let’s walk through the logic.

The guard. if (isScrolling.current) return is the debounce. The first event of a flick passes because the lock is false; it immediately sets the lock to true, so every subsequent event in that burst hits this line and bails out.

Direction. e.deltaY > 0 || e.deltaX > 0 ? 1 : -1 turns the wheel delta into a simple +1 (next) or -1 (previous). Checking deltaX too means a horizontal trackpad swipe works as well as a vertical one.

The move. Inside setCurrent, we compute the clamped next index, update the progress bar, and write the translateX transform directly to the track’s DOM node so the CSS transition animates the slide.

The release. setTimeout(..., 900) unlocks after 900 milliseconds. That number is chosen to comfortably outlast both the burst of events and the 0.8s slide animation, so we never unlock mid-animation and let a lingering event skip ahead. Tuning this value directly changes how the scroll feels — smaller feels twitchy, larger feels sluggish.

flowchart TD
  A["wheel event"] --> B{"isScrolling.current?"}
  B -->|true| C["ignore"]
  B -->|false| D["lock = true, move one panel"]
  D --> E["setTimeout 900ms"]
  E --> F["lock = false"]

Why preventDefault needs passive: false

By default the browser treats wheel listeners as passive — a performance optimization that promises you won’t call preventDefault(), so it can scroll without waiting for your code. But we must prevent the default, otherwise the page would also try to scroll normally underneath our custom animation.

To be allowed to call e.preventDefault(), you have to opt out of passive mode explicitly:

tsx
window.addEventListener("wheel", onWheel, { passive: false })

Without { passive: false }, the preventDefault() call is silently ignored and you get a console warning. This is the detail everyone trips on first.

The same slideshow also accepts keyboard navigation and, on touch screens, swipe gestures — each one just another way to advance current.

Takeaways

  • A wheel flick fires a burst of events; debounce so only the first one acts.
  • Use a useRef boolean lock, not state — refs update synchronously and don’t re-render.
  • Set the lock immediately, release it with a setTimeout that outlasts your animation (900ms here for a 0.8s slide).
  • To call preventDefault() in a wheel handler you must register it with { passive: false }.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.