Sticky, or fixed, navigation is a popular design choice because it gives users persistent access to navigate the site. On the other hand, it takes up space on the page and sometimes covers content is a way that’s less than appealing.
A possible solution? Smart navigation.
Let’s define “smart navigation” as:
- Visible at the top of the page
- Visible when the user moves up the page (wherever they may have scrolled to)
- Hidden when the user moves down the page
Here’s an example of how that might work:
It‘s all the convenience of sticky positioning, with an added fullscreen benefit. This sort of smart navigation is already commonly (think of the URL bar in many mobile browsers), but is sometimes a hassle to implement without a library or plugin. So, in this article, we’ll discuss how to build one using CSS and vanilla JavaScript.
Side note: People have different definitions of what scrolling down a page means (imagine how some trackpad preferences scroll the page up when you move your fingers down). For the purposes of this article, scrolling down refers to moving towards the bottom of the page.
Let’s look at the code
Here’s some example HTML. Our smart navigation will be the <nav>
which sits above the <main>
:
<nav>
<div class="logo">
Logo
</div>
<div class="links">
<a href="#">Link 1</a>
<a href="#">Link 2</a>
<a href="#">Link 3</a>
<a href="#">Link 4</a>
</div>
</nav>
<main>
<!--Place the content of your page here-->
</main>
It’s important to note that elements are only sticky relative to their parent container. The parent container of <nav>
should be the body tag; it shouldn’t be placed within another tag on the page.
The CSS for our smart navigation looks like this:
nav {
position: sticky;
top: 0;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 1.5rem 2rem;
background-color: #eaeaea;
}
Now we need to detect when our user is scrolling the page and the direction of their scrolling. A user is scrolling down if the value of their last scroll position is less than the value of their current scroll position. Breaking the logic down, we’ll need to:
- Define a variable to store the previous scroll position
- Assign a variable to detect the current scroll position set to the scroll offset of the page
If the current scroll position is greater than the previous scroll position, then the user is scrolling downwards. Let’s call our function isScrollingDown
:
let previousScrollPosition = 0;
const isScrollingDown = () => {
let currentScrolledPosition = window.scrollY || window.pageYOffset;
let scrollingDown;
if (currentScrolledPosition > previousScrollPosition) {
scrollingDown = true;
} else {
scrollingDown = false;
}
previousScrollPosition = currentScrolledPosition;
return scrollingDown;
};
Here’s a visual representation of how this function works:
With this logic, we’re able to detect when the page is scrolling down so we can use this to toggle our nav styling:
const nav = document.querySelector('nav');
const handleNavScroll = () => {
if (isScrollingDown()) {
nav.classList.add('scroll-down');
nav.classList.remove('scroll-up')
} else {
nav.classList.add('scroll-up');
nav.classList.remove('scroll-down')
}
}
If the user is scrolling down, we’ll assign a .scroll-down
class that contains our styling method for when the page is moving downward. We can update our <nav>
CSS to this:
nav {
/* default styling */
transition: top 500ms ease-in-out;
}
nav.scroll-up {
top: 0;
}
nav.scroll-down {
top: -100%;
}
With this styling, the top
property value of <nav>
is set to -100% of the page height so it slides out of view. We could also choose to handle our styling with translate
or by fading it out — whatever animation works best.
Performance
Whenever we’re working with scroll event listeners, performance is something that should immediately come to mind. Right now, we’re calling our function every time the user scrolls the page, but we don’t need to detect each pixel movement.
For this case, we can implement a throttle function instead. A throttle function is a higher order function that acts as a timer for the function passed into it. If we throttle a scroll event with a timer of 250ms, the event will only be called every 250ms while the user scrolls. It’s a great way to limit the number of times we call the function, helping with the performance of the page.
David Corbacho goes deeper into throttle implementations in this article.
A simple throttle implementation in JavaScript looks like this:
// initialize a throttleWait variable
var throttleWait;
const throttle = (callback, time) => {
// if the variable is true, don't run the function
if (throttleWait) return;
// set the wait variable to true to pause the function
throttleWait = true;
// use setTimeout to run the function within the specified time
setTimeout(() => {
callback();
// set throttleWait to false once the timer is up to restart the throttle function
throttleWait = false;
}, time);
}
Then we can include our handleNavScroll
function inside a throttle:
window.addEventListener("scroll", () => {
throttle(handleNavScroll, 250)
});
With this implementation, the handleNavScroll
function is only called once every 250ms.
Accessibility
Whenever implementing a custom feature in JavaScript, we must always take accessibility into concern. One such issue is ensuring that <nav>
is visible when it’s in focus. Browsers tend to scroll to the part of the page that currently has focus by default, but there can be certain complications when working with scroll events.
A way to ensure that <nav>
is always visible is to update the CSS to account for focus. Now our CSS looks like this:
nav.scroll-up,
nav:focus-within {
top: 0;
}
Unfortunately, the focus-within
selector isn’t fully supported across all browsers. We can include a JavaScript fallback for it:
const handleNavScroll = () => {
if (isScrollingDown() && !nav.contains(document.activeElement))) {
nav.classList.add('scroll-down');
nav.classList.remove('scroll-up')
} else {
nav.classList.add('scroll-up');
nav.classList.remove('scroll-down')
}
}
In this updated function, we only apply the scroll-down
class if the user is scrolling down the page and the <nav>
doesn’t currently have any element with focus in it.
Another aspect of accessibility is the consideration that some users may not want to have any animation on the page. That’s something we can detect and respect with the prefers-reduced-motion
CSS media query. We can update this method in JavaScript and prevent our function from running at all if a user prefers reduced motion:
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
window.addEventListener("scroll", () => {
if (mediaQuery && !mediaQuery.matches) {
throttle(handleNavScroll, 250)
}
});
Wrapping up
So, there we have it: a smart navigation implementation with plain CSS and vanilla JavaScript. Now users have persistent access to navigate the site without losing real estate in a way that blocks content.
Plus, the benefit of a custom implementation like this is that we get a delightful user experience that isn’t over-engineered or sacrifices open performance or accessibility.
For the throttle, I believe it makes more sense to run the function immediately (if the cooldown is up) than scheduling it to wait for the throttle timer – it makes the action more responsive (actions should happen within 100ms iirc) as a 250-500ms wait feels sluggish for a feature that usually happens immediately
Weird, the functional example for the scroll function works for me, but the nav-bar example doesn’t (android, chrome).
Opinion to consider before you add that to your webpage: I hate those navbars as a user. They always have a tendency to appear and cover the parts that I try to read on my mobile phone.
I scroll down to the part that I’m interested in so that I use all my screen estate and stop scrolling there – but to get there I often have to do a small “upward” movement and – boom – the top of the visible area is not the top of the visible anymore because that menu decides to cover the top part. So I have to scroll down again. Menu disappears – so I have the urge to scroll up again to use all screen estate. But when I stop: Menu re-appears and covers my content again.
Aargh.
Thanks! Easy to follow and helpful!
I really hate those type of navbars. It’s very unintuitive also. You scroll down, read something and then want to naviagte to a different page, but there’s no menubar anymore. Now the user ha to know to scroll up a little bit to show the menu bar again.
I think it’s better to show a visual queue where the menubar is. A nice way would be to collapte the bar into a floating button. That way the user can always press the button to open up the navbar again.
A solution would be to show the nav once the user stops scrolling.
Great post. I love a vanilla JS solution! ❤️
Just a thought but could you use the
DOMTokenList.replace()
method here?So instead of this:
You’d have something like this:
Thanks for sharing!
Does anybody know how to resolve the header not coming back on Safari on iPhone/iPad? It’s almost like it doesn’t realise it’s back at the top of the window.