Detecting scroll direction with JavaScript

Detecting scroll direction with just pure JavaScript is quite straightforward.

First step is to create an event listener for a scroll event. Event handler function that we pass to this listener is called every time user scrolls the page up or down.

Inside the event handler we compare the current Y position and the previous Y position.

If the current position is greater than the previous position, then obviously the direction is down. And if the current position is lower than the previous position, then the direction is up.

let yprev = window.pageYOffset;

window.addEventListener('scroll', () => {
  const y = window.pageYOffset;
  console.log(y > yprev ? 'down' : 'up');
  yprev = y;
});

Fixing the overscroll bouncing issue

The scroll direction detection introduced above doesn’t work as expected in some browsers, especially mobile ones.

These browsers have a feature called overscroll effect (also known as rubber band effect or elastic scrolling) that allows user to continue scrolling after the top or bottom of the page is reached. Browser then automatically bounces back to min/max boundaries of the scrolling area when user releases the pull gesture. This bounce effect causes the scroll direction to change automatically, and it breaks our detection script.

There is a CSS property called overscroll-behavior that should disable the overscroll effect when set to “none”, but it’s not yet supported in all major browsers, like Safari 15 and below.

To fix this in our JavaScript code, we want to update the previous Y with the current Y only when the current Y is inside the boundaries of the document. Otherwise we set the previous Y to the boundary value (min or max) that has been reached.

let yprev = window.pageYOffset;

window.addEventListener('scroll', () => {
  const y = window.pageYOffset;
  const ymax = document.documentElement.scrollHeight - window.innerHeight;

  if (y > yprev) {
    console.log('down');
  }
  else if (y < yprev) {
    console.log('up');
  }

  // This limits the yprev between 0 and ymax
  yprev = Math.min(Math.max(y, 0), ymax);
});

Optimizing the performance

Since scroll events on regular web pages are usually happening at a high rate, the event handler should not execute DOM modifications or other expensive operations directly.

Most commonly used patterns in JavaScript, that are used to optimize event handler performance, are called throttle and debounce.

In a nutshell, the throttle pattern calls a function at intervals of a specified amount of time while user is carrying out an event, and the debounce pattern calls a function when the user hasn’t carried out an event in a specified amount of time.

Which one to use, depends on the situation where you use the scroll event.

One of the most common use case for the scroll direction detection is to hide a fixed-positioned navigation bar when user scrolls down, and show it again when user starts scrolling back to up. If you use the debounce pattern here, it basically allows user to keep scrolling with the navbar shown/hidden, and fires the show/hide function after user has stopped the scroll event for a specified amount of time. If you want it behave so that the navbar shows up during the scroll-up event, then you might want to use throttling instead.

Let’s implement this in practice, first without any optimizations.

Showing and hiding the navbar depending on the scroll direction

Instead of just hiding and showing the navbar, we manipulate the top margin of it, using negative margin to hide it and zero margin to show it. This allows us to add a nice animation by using CSS transition-duration property, so it looks like the navbar is sliding up and down.

const navbar = document.querySelector('.navbar');
let yprev = window.pageYOffset;

window.addEventListener('scroll', () => {
  const y = window.pageYOffset;
  const ymax = document.documentElement.scrollHeight - window.innerHeight;

  if (y > yprev) {
    navbar.style.marginTop = '-100px';
  }
  else if (y < yprev) {
    navbar.style.marginTop = '0px';
  }
  
  yprev = Math.min(Math.max(y, 0), ymax);
});

Now, let’s implement the throttle pattern to optimize this.

const navbar = document.querySelector('.navbar');
let yprev = window.pageYOffset;

const throttle = (callback, timeout) => {
  let wait = false;

  return () => {
    if (wait) return;
    callback.call();
    wait = true;
    setTimeout(() => { wait = false; }, timeout);
  };
};

const handler = () => {
  const y = window.pageYOffset;
  const ymax = document.documentElement.scrollHeight - window.innerHeight;

  if (y > yprev) {
    navbar.style.marginTop = '-100px';
  }
  else if (y < yprev) {
    navbar.style.marginTop = '0px';
  }
  
  yprev = Math.min(Math.max(y, 0), ymax);
};

window.addEventListener('scroll', throttle(handler, 250));