How to debounce wheel scrolling so one flick advances one panel
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:
// 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:
// 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:
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
useRefboolean lock, not state — refs update synchronously and don’t re-render. - Set the lock immediately, release it with a
setTimeoutthat outlasts your animation (900ms here for a 0.8s slide). - To call
preventDefault()in a wheel handler you must register it with{ passive: false }.