📝 Originally published (in Japanese) at forge.workstyle.tech.
TL;DR Wheel event counts do not match the distance moved, so counting events can skip pages. Convert wheel distance into progress using the page height. After input stops, use the progress to choose the destination, and handle touch input separately on mobile.
Conclusion First
If page turns are based on the number of wheel events, a small amount of scrolling can advance multiple pages. That’s because the number of wheel events doesn’t correspond to the distance traveled. You can make page changes match the amount of scrolling by dividing the distance by the page height, then—once scrolling stops—using the halfway point to decide whether to move to the next page or return to the current one.
Why Does Scrolling Send the Page Flying?
The page was jumping because each wheel event was treated as a page turn. Scroll input received during the 0.8-second transition was discarded, so the next action wasn’t accepted until the trackpad’s inertia had stopped.
I changed it to remember up to one page’s worth of input during a transition and shortened the wait before accepting the next action to 0.55 seconds. Even so, counting events could make a single page’s worth of scrolling advance two or three pages.
So I count distance instead of events. One screen height counts as one page. Scrolling in the opposite direction, and any distance accumulated after a 1.2-second pause, is discarded. With the screen at 1280Ă—800 and the slide height at 712px, a test scrolling in 100px increments advanced to page 2 at 800px, page 3 at 1500px, and page 4 at 2200px.
Turning Wheel Distance into Page Progress
Scrubbing changes how far a page transition has progressed based on the input distance. Track the current progress as progress: 0 means the current page, 1 means the next page, and -1 means the previous page. Divide the wheel movement by the page height and add the result to the progress.
export function scrubStep(progress, delta, page, { back, forward }) {
let next = progress + delta / page;
if (!forward) next = Math.min(next, 0);
if (!back) next = Math.max(next, 0);
if (next >= 1) return { progress: Math.round((next - 1) * 1e6) / 1e6, turn: 1 };
if (next <= -1) return { progress: Math.round((next + 1) * 1e6) / 1e6, turn: -1 };
return { progress: Math.round(next * 1e6) / 1e6, turn: 0 };
}
export const settleTarget = progress =>
(Math.abs(progress) >= .5 ? Math.sign(progress) : 0);
Pass the wheel movement as delta and the height of one page as page. If the wheel event's deltaMode is line-based, multiply the movement by 16; if it's page-based, multiply it by the slide height before using it.
When progress reaches 1 or more, move to the next page; at -1 or less, move to the previous page. Any remaining progress is carried over after the page turn, so movements greater than one page can be handled. Progress does not move in a direction where navigation is unavailable.
settleTarget decides where to settle after scrolling stops. If the absolute progress is at least 0.5, it settles on the page in that direction; if it's less than 0.5, it returns to the current page. For example, stopping at 360px advanced to the next page, while stopping at 240px returned to the current page.
Decide where to go when input stops, using the halfway point
Waiting for input to stop prevents the page from settling after every brief input. Once 160ms have passed since the last input, check the progress and use the halfway point to decide whether to move forward or back.
The duration of the transition depends on the distance to the destination:
const duration = 120 + 260 * distance;
distance is the distance from the current progress to the destination. Use cubic ease-out, which slows down toward the end, for the animation. Since the page keeps moving during the transition, you can build the decision based on where it stops separately from the final movement.
Why Soft Is the Default Easing
Easing is a way to adjust how an animation progresses. With linear easing, movement is proportional to progress, so the stop can look mechanical. Strong easing barely moves at the start, which can make it feel unresponsive.
In this implementation, soft—the average of linear and smoothstep—is the default.
export const EASES = {
linear: p => p,
soft: p => (p + p * p * (3 - 2 * p)) / 2,
strong: p => p * p * (3 - 2 * p),
};
However, I haven’t verified which easing was ultimately selected. This explanation assumes the default, soft, is being used.
The visual properties change in proportion to progress: translate3d, rotateY, and opacity. For the outgoing page, I used [-14%, -3%, 350px, 14deg]; for the incoming page, I used [12%, 4%, -650px, -20deg]. Pages swapped through scrubbing do not get the entrance animation that assembles the heading.
On smartphones, release your finger to decide the page
On the smartphone version, finger movement takes the place of the mouse wheel. If your finger is moving faster than 0.6 pages per second when you lift it, the page advances in that direction. Below that threshold, the destination is determined by whether progress is at least 0.5.
On pages with long content, finger movement is split between scrolling through the content and switching pages. Scroll to the end of the content first, then switch pages.
What the behavior checks revealed
The checks verified that progress changes as the wheel is turned in small increments, and that stopping partway through changes the destination based on whether the position is past the halfway point. With a 1280×800 screen and slides 712px tall, the cover’s opacity changed to 95%, 88%, 79%, 70%, 60%, and 49% as the wheel was turned in 60px increments. When scrolling stopped at 360px, it advanced to the next page; at 240px, it returned to the original page.
We ran 29 tests to check this behavior. When recording measurements, note both the screen height and the slide height so you can track what distance was treated as one page. Advancing pages by counting wheel events can make the number of pages change with each input. Converting distance into progress and, after scrolling stops, snapping to the nearer page based on the halfway point keeps the amount of scrolling aligned with page movement.
Key takeaways
- Record the screen and slide heights to establish the distance for one page.
- Add wheel distance divided by page height to progress.
- Discard input in the opposite direction and input accumulated during a 1.2-second pause.
- Check progress 160ms after the last input and settle toward the next or current page at the 0.5 threshold.
- On mobile, check release velocity and scroll long page content to the end before changing pages.
Top comments (1)
Normalizing distance instead of counting wheel events is the key correction, and handling deltaMode prevents a hidden device-dependent bug. I would also keep keyboard navigation and prefers-reduced-motion behavior on the same state machine, then test high-resolution trackpads, traditional wheel mice, and touch separately. Scroll-driven transitions feel polished only when the fallback interaction remains predictable and accessible.