How to make a sticky Navbar that hides on scroll (React/Hooks)

July 27th, 2020

Note - In the future I will host links to demos so you can see the code in action. In this case, aside from some extra styling, the code below is the exact code that powers the navbar on this site!

Also, If you want to skip ahead and see the final product, the complete code for the navbar is at the bottom.

This post will show you how to make a Navbar that "hides" by sliding up and off the page when the user starts to scroll down - the Navbar then glides down and back into view when the user scrolls back up toward the top of the page. I will be using React with Hooks.

This is a very common effect used for Navbars - especially on news sites and blogs that have a lot of readable content. Giving the user space to read is crucial if you want them to enjoy spending time on your site.

Making the Navbar component

In a new React project, make a file for your Navbar component. Mine will be located at src/components/Navbar.js:

src/components/Navbar.js

import React, { useState } from 'react';

const Navbar = () => {

const navbarStyles = { position: 'fixed', height: '60px', width: '100%', backgroundColor: 'grey', textAlign: 'center' }

return ( <div style={{ ...navbarStyles }}> Some Company Inc. </div> ); };

export default Navbar;

This is very bare-bones - just a grey navbar with a company title in the center. In a real project there would be links added and more styling, but I am keeping it simple so we can stay on topic.

I am using objects to spread inline styles to keep the styling all in one file. Feel free to used styled components, or any other preferred styling options.

Side note: when coding in React I prefer to keep my styles inside my Javascript files right next to my components because I like having everything visible and all in one place - I also think it makes conditional styling easier (which we will be doing here shortly).

Adding some State

Now that we have the basic skeleton of our navbar, let's add some state using React's useState hook. We will eventually use our state to determine whether to show or hide the navbar.

Your updated Navbar.js file should now look like this:

src/components/Navbar.js

import React, { useState } from 'react'; <-- updated! const Navbar = () => { const [prevScrollPos, setPrevScrollPos] = useState(0); <-- new! const [visible, setVisible] = useState(true); <-- new! const navbarStyles = { position: 'fixed', height: '60px', width: '100%', backgroundColor: 'grey', textAlign: 'center' } return ( <div style={{ ...navbarStyles }}> Some Company Inc. </div> ); }; export default Navbar;

All we did here was import useState and add two pieces of state to our component: prevScrollPos and visible.

The first, prevScrollPos, will represent the position of the "previous scroll." For example, when the user scrolls down the page, this piece of state will store where the user was on the page prior to beginning the scroll - as you can see, it is set to zero at the start, but it will always be a number representing a distance (measured in pixels) from the top of the page. Don't overthink this yet, it will make more sense a bit further down.

The second piece of state, visible, is a boolean value (true or false) that shows the Navbar when true and hides it when false.

However, right now these pieces of state are just sitting there not doing anything. Let's change this by adding a useEffect hook that tacks on a scroll event listener to the window when the component mounts, and add some conditional styling to the navbar:

src/components/Navbar.js import React, { useState, useEffect } from 'react'; <-- updated! const Navbar = () => { const [prevScrollPos, setPrevScrollPos] = useState(0); const [visible, setVisible] = useState(true); const navbarStyles = { position: 'fixed', height: '60px', width: '100%', backgroundColor: 'grey', textAlign: 'center' } const handleScroll = () => { <-- new function! // find current scroll position const currentScrollPos = window.pageYOffset;

// set state based on location info (explained in more detail below) setVisible((prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70) || currentScrollPos < 10);

// set state to new scroll position setPrevScrollPos(currentScrollPos); }; useEffect(() => { <-- new useEffect! window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [prevScrollPos, visible, handleScroll]); return ( <div style={{ ...navbarStyles, top: visible ? '0' : '-60px' }}> <-- updated styling! Some Company Inc. </div> ); }; export default Navbar;

Okay, let's discuss what we added here.

We will start with that scroll event listener we added inside the useEffect hook.

Adding the scroll event listener

If you are not familiar with useEffect, think of it as the hook that fires once the component has mounted. It will then fire every time one of its dependencies (the items passed inside its "dependency array" - in this case prevScrollPos, visible, and handleScroll) changes. This is an oversimplification of useEffect but it will work for our purposes.

In our useEffect, we are adding an event listener to the window - in this case, a scroll event listener that will fire off our handleScroll function each time the user scrolls up or down the page. Note that we are also being "good react citizens" and "cleaning up" after ourselves inside the useEffect by including the return function that fires and removes the event listener when the component unmounts. If we forgot to do this, multiple event listeners would keep getting added to the window, and so each would fire whenever the user scrolled, which we definitely want to avoid.

It is a common occurrence in React to call upon the window in some way inside the useEffect hook, such as adding an event listener, as we do here - in these cases (as well as many others), always remember to include a cleanup function if necessary - it can be easy to forget to do!

Okay, so the handleScroll function fires every time the user scrolls, so what does this handleScroll function actually do?

handleScroll

As you can see above, the first thing the handleScroll function does is store the value of the current location of the window (aka the "post scroll" location) inside the currentScrollPos variable. It does this by calling window.pageYOffset, a window method that essentially returns the answer to the question: "how many pixels are we from the top of the page?"

Next, we want to use this updated currentScrollPos value to determine whether or not we should show or hide the navbar.

We know that we want to hide our navbar as the user scrolls down the page, but show it again should the user start to scroll back up the page. So, we need to know whether the most recent scroll was an "up scroll" or a "down scroll"...

We can accomplish this by determining whether the value of the location of the window prior to the scroll (aka prevScrollPos) is greater than our newly updated currentScrollPos. If the previous distance from the top of the window is greater than the newly updated distance, we know that the user has indeed just scrolled back up toward the top of the page.

Don't feel bad if it takes a second to digest this, but it makes perfect sense once you do!

And so it also follows that if the old value is less than the new, the opposite is true, and the user has just scrolled down the page.

As discussed above, the piece of state that decides whether the navbar is showing or hiding is visible. So now, inside the handleScroll function, we use setVisible to set visible to true if the case (described in detail above) is true:

const handleScroll = () => { ... setVisible(prevScrollPos > currentScrollPos); ... };

But wait, that's not what the code says above! What's the other stuff being left out?

Ah, how could I forget?

Well, we don't want to toggle the navbar on literally every last itty bitty scroll - what if the user scrolls back up a smidge just to read one line that is barely off-screen? It would be kind of annoying to the reader to have the navbar be that sensitive to movement.

So, let's not have the navbar drop back into view when the user scrolls back up the page unless the user has scrolled at least 70 pixels in one scroll. The 70 pixels is arbitrary, but it seemed like a good distance to me when I played around with it - feel free to make it 50 pixels, 80 pixels, or whatever feels/looks right to you.

So now we arrive at this:

const handleScroll = () => { ... setVisible(prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70); ... };

But wait, there's more!

What if the user scrolls only 60 pixels down the page, but then calls it quits and scrolls back up to the very top? If we left the code snippet above as is, the navbar would never reappear!

This is a bad look - we definitely want the navbar to always be showing whenever we are back at the top of the page. So, for good measure, let's ensure the navbar always shows whenever we are within 10 pixels of the very top of the page.

Finally, we arrive at the final form of the second piece of our handleScroll function:

const handleScroll = () => { ... setVisible(prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70 || currentScrollPos < 10); ... };

There is only one simple thing we need to do inside our handleScroll function before we break out of it: update the "previous scroll" state (prevScrollPos) to be the new currentScrollPos - so that the next time handleScroll fires, it is using the window location "where we last left off."

So, aside from one small update we will make at the very end, here is our handleScroll function in its entirety:

const handleScroll = () => { const currentScrollPos = window.pageYOffset; setVisible((prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70) || currentScrollPos < 10); setPrevScrollPos(currentScrollPos); };

Conditionally styling the Navbar using state

Now that the handleScroll function is updating the necessary piece of state (visible) when the user scrolls, let's make it official by using inline styles, the CSS top property, and the always useful ternary operator to show/hide the Navbar.

The Navbar <div> being returned should now look like this:

<div style={{ ...navbarStyles, top: visible ? '0' : '-60px' }}> Some Company Inc. </div>

Wait, why don't we just use the CSS display property to toggle the Navbar? Why are we using top with a negative number to stash the navbar out of sight above the window? Seems trashy...

The answer to this fair question is that we don't just want the Navbar to appear and disappear instantaneously - that would be a little jarring and not make for the best UI.

Instead we want to add an animation to make it glide smoothly on and off the page. This is a very quick and easy update - let's do it now!

Making the Navbar move smoothly with CSS transition

All we have to do to make the Navbar transition smoothly is add this one line to the Navbar's style object:

... const navbarStyles = { position: 'fixed', height: '60px', width: '100%', backgroundColor: 'grey', textAlign: 'center', transition: 'top 0.6s' <-- new! } ... return ( <div style={{ ...navbarStyles, top: visible ? '0' : '-60px' }}> Some Company Inc. </div> ); ...

This tells the Navbar "hey, whenever something triggers your top property to change, make that UI change happen in 0.6 seconds, rather than instantaneously."

Since the change happening in our code is an upward shift of the Navbar to be 60 pixels above the top of the screen, this will create the desired "upward slide" effect.

Yes, 0.6 seconds is not a long time, but it is just long enough to give the effect of a glide. Feel free to alter the count to speed up or slow down the transition to your liking.

One final update: adding debounce()

We are basically done, but there is room for one final improvement.

Right now, our handleScroll function is being triggered for literally every pixel of scroll movement. In other words, because it is directly attached to our scroll event listener with no buffer in between, this function gets fired a zillion times whenever the user scrolls.

Not only is this unnecessary, it can also break the UI as it can cause our Navbar component to flicker or generally spazz out, as its state is being asked to update at an absurd speed - the browser will probably have trouble keeping up with all these re-renders.

The "too many renders" problem is a very common theme in React, and something you should always consider whenever you build a stateful component!

Debounce to the rescue!

The debounce function should be a tool in every developer's arsenal. In short, to quote David Walsh, what it does is "limit the rate at which a function can fire."

Not only will debounce be useful for this post/tutorial, but once you know that it exists, you will see that it can come in handy in many other places.

This will allow us to set a limit (in milliseconds) on how often our handleScroll function can fire.

For the sake of keeping things organized, and because debounce is not a native Javascript method, we are going to copy and paste the code to the debounce function (using the version found on David Walsh's blog) into a new file in our project. Then, we will export the function from there and import it and use it inside our Navbar.js file.

So, let's first create a file where we can store useful functions that can be exported anywhere across our project. Then, let's copy, paste, and lastly export the debounce function we are stealing from David Walsh.

I will call this file helpers.js and by convention I will keep it inside a folder called utilities that lives inside the src folder - but feel free to store it wherever you want, so long as you can still export it:

src/utilities/helpers.js

export function debounce(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; };

Now that we have exported debounce inside our codebase, all we need to do is import it into Navbar.js to use it on our handleScroll function.

Here is what the syntax looks like when using debounce (in this case we are going to set the time interval to 100 milliseconds):

src/components/Navbar.js ... import { debounce } from from '../utilities/helpers'; ... const handleScroll = debounce(() => { <-- debounce wrapper added! const currentScrollPos = window.pageYOffset; setVisible((prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70) || currentScrollPos < 10); setPrevScrollPos(currentScrollPos); }, 100); <-- timer set to 100 milliseconds! ...

And we're done!

The final product

Thanks for reading! Here is the final Navbar component:

src/components/Navbar.js

import React, { useState, useEffect } from 'react'; import { debounce } from '../utilities/helpers';

const Navbar = () => { const [prevScrollPos, setPrevScrollPos] = useState(0); const [visible, setVisible] = useState(true);

const handleScroll = debounce(() => { const currentScrollPos = window.pageYOffset;

setVisible((prevScrollPos > currentScrollPos && prevScrollPos - currentScrollPos > 70) || currentScrollPos < 10);

setPrevScrollPos(currentScrollPos); }, 100);

useEffect(() => { window.addEventListener('scroll', handleScroll);

return () => window.removeEventListener('scroll', handleScroll); }, [prevScrollPos, visible, handleScroll]);

const navbarStyles = { position: 'fixed', height: '60px', width: '100%', backgroundColor: 'grey', textAlign: 'center', transition: 'top 0.6s' }

return ( <div style={{ ...navbarStyles, top: visible ? '0' : '-60px' }}> Some Company Inc. </div> ); };

export default Navbar;

© 2020 All Rights Reserved