Want to hide navigation bar when user scrolls down, and then again show it when user scrolls up? This is where you'd want to detect the scroll direction using JavaScript.

Let's start with a simple example:

let yprev = window.pageYOffset;

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

First we create an event listener for a scroll event, and pass an event handler to this listener. This event handler 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 the direction is down. And if the current position is lower than the previous position, then the direction is up.

Dealing with the overscroll bouncing issue

The issue with above example is that it doesn't take care of situations where so-called overscroll effect (also known as rubber band effect or elastic scrolling) happens.

Overscroll effect is a feature in mobile browsers that allows user to continue scrolling, using a pull gesture, 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 causes the scroll direction to change automatically.

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 mobile 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 that are used to optimize event handler performance in JavaScript, 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));

Inside our throttle() function we are calling the event handler function only if we haven't called it for the last 250 milliseconds. This can save a huge amount of resources if we are doing some expensive tasks inside the event handler, like modifying the DOM.