Detect scroll direction in vanilla JavaScript
I wanted to try out a goofy idea. I wanted to animate a website’s logo so that it would fall over when you scroll down, and right itself when you scroll up. In particular, I wanted the logo to be a head or have a cartoonish look. This is my first try:
What do you think?
There are more practical use cases for detecting the direction that the user is scrolling. Let’s look into them, and then we can walk through some code.
Use cases
A common use case for detecting scroll direction is to have navigation options hidden when you scroll down, but show them when a user scrolls up. This acts as a rough indication that a user wants to go somewhere else. There is no Mind Reading API in JavaScript, so this is a best guess! 😉
I have seen some people refer to a main navigation that follows this behaviour pattern as a “smart nav bar”. Some people like this behaviour, others don’t. The main complaint I have seen is that the peek-a-boo nature of a “smart nav” can be cover some text that you are reading when you are on a mobile device.
My take is that it is better to show a smart “back to top” button instead. This button will take them to the top of the page, where they will have access to the main navigation. I will cover this in my next post. Here is an example.
Sometimes it can be fun to take a technique and look for unusual places to apply it. Maybe, this will spark an idea in you!
In my case, it was a goofy idea that set me down the path to explore this.
Let’s step through the code.
The code
The main ingredients are the Window interface to tell us about the browser window that displays your page, and a “scroll” event listener to enable us to react to changes in the scrollbar (scroll events).
We can consult the property window.pageYOffset
to know the number of pixels the page is currently scrolled along vertically. This is an alias for window.scrollY
. There is slightly better browser support for pageYOffset
than for scrollY
(Internet Explorer 9+), but if you’re not concerned about browsers that far back, you can use either one.
A user is scrolling down when the value of their previous scroll position is less than the value of their current scroll position. We will create a function and call it isScrollingDown
. It will return a boolean value.
Now that we can tell what the scroll direction is. Let’s react to this!
How about we change the style of our top heading (h1
) to reflect the scroll direction?
We will make the heading sticky, so it always appears on the top of the screen. In our JavaScript code, we will add a class to change the style based on the scroll direction.
When we scroll up, we will append the word “up” to the heading text with a red background, as below in screenshot.
When we scroll down, we will append the word “up” to the heading text with a green background, as below in screenshot.
We can style the up and down states by creating separate classes that have an ::after
psuedo element:
We will create a handler function called handleScroll
that will add a “scroll-down” or “scroll-up” class to the heading to match the direction we are going.
Give it a try!
However, whenever you work with scroll event listeners, performance is something that needs some consideration.
Performance considerations
Right now, we call our handler function every time the user scrolls the page by a few pixels. This could mean our handler function is called many times per second if we are scrolling quickly through a page. This is unnecessary, we don’t need that sort of percision.
We can use a throttle function to limit the number of calls i.e. do an action once per X amount of time. A related concept is debouncing, and sometimes people get them confused. This article explains both concepts and has an interactive ball machine to demonstrate throttling.
The throttle
function should accept at least two arguments: func
, which is a function to throttle, and wait
, which is the duration (in milliseconds) to wait until the next execution. It returns a throttled function.
So say, we want to limit our scroll functionality to be called a maximum of 10 times per second. We can provide 100 milliseconds for wait
. There are implementations that also accept leading
and trailing
parameters that control the first (leading) and the last (trailing) function calls. In our case, to be responsive immediately, we want the first function call to be executed.
If you want to grab a throttle
function from elsewhere, you can find one in the following places:
- Lodash and underscore are 2 popular utility libraries.
- If you just want to copy and paste a single block of code, the most complete and trustworthy version I found is from the underscore library. I have included it the appendix section.
- This GitHub Gist has some different versions using modern JS. You can see there is some debate here, some people have slightly different interpetations of what a throttle function is exactly! So, be careful.
I include the lodash library to use its throttle
function. You can check the function description in the docs to see how to use it. This is the interface:
We create a throttled function by passing the first 2 paramaters in:
- our scroll handler function as the function to throttle (
func
), - and a wait period of 100 milliseconds (
wait
).
By default, this will execute the first time the function is run (the leading
option is true).
We pass the throttled function to the scroll event listener.
And here it the modified version with throttling:
You can see the user experience is the same, but the performance behind the scenes is better. You can play with different values for wait
to see what is the best result for your use case.
How about making the event listener passive?
You may be aware that making some event listeners “passive” can improve scroll performance. Many times when you encounter a delay (scroll jank), the culprit is a touch event listener. This is because some events cancel scrolling, and because browsers can’t know if a touch event listener is going to cancel the action, they wait for the listener to finish before scrolling the page.
Passive event listeners solve this problem by enabling you to set a flag in the options parameter of addEventListener
indicating that the listener will never cancel the scroll. That information enables browsers to scroll the page immediately, rather than after the listener has finished.
However, Google says that it is superfluous for scroll events:
The basic scroll event cannot be canceled, so it does not need to be set passive. However, you should still prevent expensive work from being completed in the handler.
Also, I think setting that you want the listener to be invoked during the capture phase through the capture
option should not have any impact on performance. For a window event, the capture and bubble phases happen almost back-to-back.
If I am not correct on any of these points, please let me know!
Can you use an Intersection Observer instead?
If you are not familar with the Intersection Observer API, it is a performant way to detect if a target element is visible. It checks if an element intersects with the viewport (or another specified element). For our use case, to find out about scroll direction, we would be using it for something a bit left of its purpose.
The short answer to the question posed is that we cannot use Intersection Observer API for page-level scrolling (in a straightforward way). The way you can tell if scrolling is taking place is when an element comes into and out of view, you get feedback through a callback triggered by how much of an element is visible determined by a threshold
option. Since the body
is likely to be always visible relative to the root element and the amount visible will only vary a tiny bit, you may only get a single callback.
I am not that experienced with Intersection Observer, so maybe there is a clever hack to get this behaviour to work. If there is something, please let me know.
You can get the scroll direction of a target element that moves in and out of view. This stackoverflow question covers that.
Appendix
This is the throttle function from the ESM (Development) version of underscore with just one change so it runs everywhere (I replaced now()
with Date.now()
):