javascripthtmlbrowserscrollsmooth-scrolling

How to *Accumulate* Consecutive `scrollBy` Calls with `behavior: "smooth"` for Smooth Scrolling?


Background

I’m implementing a feature where pressing Alt enables scrolling 5x faster, similar to the behavior in VSCode. Below is the relevant code snippet (full code can be found at CodePen):

/**
     * Scrolling speed multiplier when pressing `Alt`.
     * @type {number}
     */
    const fastScrollSensitivity = 5;
    function findScrollableElement(e, vertical = true, plus = true) {
        // -- Omitted --
    }
    /**
     * Handle the mousewheel event.
     * @param {WheelEvent} e The WheelEvent.
     */
    function onWheel(e) {
        if (!e.altKey || e.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        const { deltaY } = e;
        const amplified = deltaY * fastScrollSensitivity;
        const [vertical, plus] = [!e.shiftKey, e.deltaY > 0];
        const el = findScrollableElement(e, vertical, plus);
        Object.assign(lastScroll, { time: e.timeStamp, el, vertical, plus });
        el.scrollBy({
            top: e.shiftKey ? 0 : amplified,
            left: e.shiftKey ? amplified : 0,
            behavior: "instant"
        });
        // TODO: Smooth scrolling
    }
    /**
     * Enable or disable the fast scroll feature.
     * @param {boolean} enabled Whether the fast scroll feature is enabled.
     */
    function fastScroll(enabled) {
        if (enabled) {
            document.addEventListener("wheel", onWheel, { capture: false, passive: false });
        } else {
            document.removeEventListener("wheel", onWheel, { capture: false, passive: false });
        }
    }

The feature works as expected when using behavior: "instant" in scrollBy. However, switching to behavior: "smooth" causes issues.


Question

The issue arises when I use behavior: "smooth". If scrollBy is called repeatedly before the previous animation ends, the subsequent call seems to interrupt the ongoing animation, leading to inconsistent behavior and slower scrolling speeds.

  1. Is it possible to let the browser accumulate consecutive scrollBy calls instead of cancelling previous ones?
  2. If not, how can I modify my code to enable smooth scrolling without these issues?

I prefer having the browser handle the smooth animations rather than manually calculating and animating via JavaScript.


Attempts

  1. Using scrollBy with behavior: "smooth"

    • Problem: Consecutive calls interrupt previous animations.
  2. Accessing scrollTop

    • Problem: When accessed before the animation ends, it returns intermediate values, which makes calculations unreliable.
  3. Smooth scrolling with requestAnimationFrame (considered but not implemented)

    • I want to avoid handling smooth animations manually if possible.

Solution

  • Instead of using scrollBy (which doesn't have as good support on old browsers as scrollTo anyway), you can use scrollTo and manually keep track of the target coordinates by adding each deltaY. Like so (full edited example on CodePen)

    const lastScroll = {
      targetX: -1, // keep track of target coordinates
      targetY: -1, // keep track of target coordinates
      time: 0,
      el: document.scrollingElement,
      vertical: true,
      plus: true,
    };
    
    // ...
    
    function onWheel(e) {
      if (!e.altKey || e.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) {
        reset();
        return;
      }
    
      // ... as before
    
      let targetX = lastScroll.targetX >= 0 ? lastScroll.targetX : el.scrollLeft;
      targetX += e.shiftKey ? amplified : 0;
    
      let targetY = lastScroll.targetY >= 0 ? lastScroll.targetY : el.scrollTop;
      targetY += e.shiftKey ? 0 : amplified;
    
      // set boundaries
      targetX = Math.max(0, Math.min(targetX, el.scrollWidth - el.clientWidth));
      targetY = Math.max(0, Math.min(targetY, el.scrollHeight - el.clientHeight));
    
      Object.assign(lastScroll, {
        targetX,
        targetY,
        time: e.timeStamp,
        el,
        vertical,
        plus,
      });
      el.scrollTo({
        top: targetY,
        left: targetX,
        behavior: "smooth",
      });
    }
    
    function reset() {
      Object.assign(lastScroll, {
        targetX: -1,
        targetY: -1,
        time: 0,
        el: document.scrollingElement,
        vertical: true,
        plus: true
      })
    }