One Wheel Flick, Two Jobs: Scroll First, Flip Later
Adding wheel paging to my AI answers popup: a listener that never attached, passive-by-default events, edge detection, and reduced motion.
TL;DR
Popup jawaban AI di SearchDialog seharusnya bisa dibalik pakai scroll wheel saat konten mentok di tepi atas atau bawah. Dua jebakan muncul: useEffect jalan saat popup masih tertutup sehingga ref-nya null, dan listener wheel butuh opsi passive false agar preventDefault tidak diabaikan browser. Animasi flip tetap hormati prefers-reduced-motion murni lewat CSS, tanpa JavaScript tambahan.
In SearchDialog.tsx there is a popup that shows AI answers. A single answer can be really long, so you scroll down to read it. You hit the bottom, spin the wheel again, and what should show up is the next answer, sliding in like a page flip. Reality: it just stuck at the edge and nothing happened.
I thought this would be easy. Attach a wheel listener, check the scroll position, and trigger the page-change animation once it hits an edge. Done, maybe a few dozen lines.
Two wrong assumptions came out of that.
The Listener Never Attached
In the first commit (23227b5) I put the listener in a plain useEffect. The logic: component mounts, effect runs, listener attached, done.
Not quite.
The popup mounts in a closed state. Its content only really exists once the open state flips to true. So when the effect first ran, sectionRef.current was still null. The listener got "attached" to a ref pointing at nothing. React runs an effect's setup when the component is added to the page and re-runs it whenever its dependencies change [5], but that first commit had no dependency signaling the popup had opened. The listener never attached at all.
The follow-up commit (6fc31f4) moved listener attachment into an effect that tracks the popup's visibility. The listener only attaches when the dialog is genuinely open, and cleanup releases it when it closes.
A small lesson about React timing. useEffect runs on mount, but when mounting happens relative to UI visibility is a separate matter. If the target element only appears under a certain render condition, the effect's dependencies have to follow that condition.
preventDefault Is Not Free
The second upside-down assumption: call event.preventDefault(), the scroll stops, the browser obeys. Also no.
For wheel events, the passive option on a listener defaults to true in browsers other than Safari [2]. Which means the preventDefault() call inside the handler gets bypassed. No error, no warning, scrolling just continues.
The fix has to be explicit:
el.addEventListener("wheel", handler, { passive: false });
Without that options object, the handler still runs but preventDefault() inside it becomes a no-op. MDN also notes that wheel is cancelable, yet in some browsers only the first event in a sequence is [1]. I have not benchmarked how far that goes, but the takeaway is clear: do not rely on preventDefault to lock every event in a scroll burst. Go non-passive only on the listener that truly needs it.
Detecting the Scroll Edge
The core question: when is a flip allowed? As long as the answer has more content to scroll, do not flip. The user is reading, not page-hopping.
The standard check: compare the pane's scrollTop against its content height. Zero means the very top. Position plus window height touching total content height means the very bottom. Only at those two edges does the wheel move to the previous or next answer.
This is exactly the scroll chaining problem MDN documents: scrollable content drinks the event first, and the page underneath only moves once the boundary is touched [3]. The first commit basically interpreted that boundary by hand: content taller than the popup gets to scroll first, the rest goes to flipping. Short answers that need no scrolling? Flip immediately, no waiting.
The rest is detail: a 400 millisecond timestamp lock so one wheel roll produces one flip, and a pageAnim state deciding which animation class lands on the popup.
The Animation We Cheerfully Drop
The flip gets its feel from the ai-page-in-left and ai-page-in-right CSS keyframes, 220 milliseconds ease-out. One page slides in from the direction the wheel came from.
The easiest part to forget lives in globals.css: both animation classes are listed in the prefers-reduced-motion: reduce media query alongside the other decorative animations. This CSS feature exists to detect users who deliberately tone down non-essential motion on their device [4]. So a user whose OS settings minimize animation can still switch answers, just without the slide.
No matchMedia in JavaScript needed. The CSS is enough, and it is more honest: one source of truth in the stylesheet, no duplicated state in the component.
The Feel Is Worth the Contract
A wheel on a small UI ends up being a two-job contract: scroll the content first, navigate at the edges. Two arrow buttons would also solve it, and would be easier to write. But the feel is different. The wheel gives access to answer history without pulling attention to extra UI, and once the listener timing is right, the extra code is not much either.
Sources:
[1] https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event
[2] https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
[3] https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior
[4] https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion