On smaller screens like phones, every bit of space counts. It’s not ideal to have the navigation bar always visible. One way to fix this is by hiding it when the user scrolls down, and showing it again when they scroll up.
In this guide, you’ll build the interaction from scratch using plain HTML, CSS, and JavaScript. We’ll use Vite for local development — it’s fast and makes mobile testing easy. But since we’re only using standard web tech, you can still follow along without Vite if you prefer.
Want a quick peek? Here’s the end result:
- 🔗 Live Demo:
View on GitHub Pages - 🔗 CodeSandbox:
- 🔗 Source code githug:
View on GitHub
Setup project
npm create vite@latest
> Name the project [your-project-name]
> Select framework > vanilla
> Select variant > Javascript
cd [your-project-name]
npm install
npx vite --host
You’ll see the Vite welcome screen. Thanks to--host
, you can now open the site on your phone by entering your computer's local IP.
Cleanup:
- Delete
src/counter.js
- In
src.main.js
remove everything except the CSS import:
import './style.css'
- in
src/style.css
, clear it and past the base styling:
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Reset style */
body, ul {
margin: 0px;
padding: 0px;
}
ul {
list-style: none;
}
- for the icons I downloaded some svg icons from
https://fonts.google.com/icons and put them inside thesrc/icons
folder. You can find theicons here .
Building the mobile layout
The structure includes two main sections: the header (logo + nav) and the main content.
📱 On mobile, navigation buttons go at the bottom for easier access.
🖥 On desktop, everything stays up top.
Here’s the HTML we’re going to use:
<div class="container">
<header class="site-header">
<div class="logo-container">
<a href="#" class="logo" aria-label="Homepage">
<img height="32px" src="/vite.svg" alt="Vite logo" />
<span>Site name</span>
</a>
</div>
<nav class="site-nav">
<ul>
<li>
<a href="#" aria-label="Projects page" class="nav-item">
<img src="src/icons/projects.svg" alt="Projects icon" />
<span>Projects</span>
</a>
</li>
<li>
<a href="#" aria-label="Articles page" class="nav-item">
<img src="src/icons/article.svg" alt="Articles icon" />
<span>Articles</span>
</a>
</li>
<li>
<a href="#" aria-label="About page" class="nav-item">
<img src="src/icons/about.svg" alt="About icon" />
<span>About</span>
</a>
</li>
<li>
<a href="#" aria-label="Contact page" class="nav-item">
<img src="src/icons/contact.svg" alt="Contact icon" />
<span>Contact</span>
</a>
</li>
</ul>
</nav>
</header>
<main class="content">
<h1>Responsive header</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer
consectetur lacus quis diam aliquam rutrum at in tortor. Fusce in
sollicitudin magna. Suspendisse nec arcu ut ante dignissim finibus eu
in ex. Duis dolor sapien, venenatis id viverra nec, scelerisque vel
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer
consectetur lacus quis diam aliquam rutrum at in tortor. Fusce in
sollicitudin magna. Suspendisse nec arcu ut ante dignissim finibus eu
in ex. Duis dolor sapien, venenatis id viverra nec, scelerisque vel
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer
consectetur lacus quis diam aliquam rutrum at in tortor. Fusce in
sollicitudin magna. Suspendisse nec arcu ut ante dignissim finibus eu
in ex. Duis dolor sapien, venenatis id viverra nec, scelerisque vel
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer
consectetur lacus quis diam aliquam rutrum at in tortor. Fusce in
sollicitudin magna. Suspendisse nec arcu ut ante dignissim finibus eu
in ex. Duis dolor sapien, venenatis id viverra nec, scelerisque vel
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer
consectetur lacus quis diam aliquam rutrum at in tortor. Fusce in
sollicitudin magna. Suspendisse nec arcu ut ante dignissim finibus eu
in ex. Duis dolor sapien, venenatis id viverra nec, scelerisque vel
</p>
</main>
</div>
Start with styling mobile first:
a {
color: #fff;
text-decoration: none;
}
a:hover,
a:focus {
background-color: #646cff;
}
.logo {
display: inline-flex;
align-items: center;
padding: 0px 12px;
}
.logo-container {
background-color: #242424;
position: fixed;
top: 0;
width: 100%;
display: flex;
padding: 8px;
}
This will make sure the logo is on the top of the page. The a styling makes sure we use the same styling for our links everywhere. The hover and focus make sure the links have the correct state when doing those. Now we will work on the bottom part with the nav:
.site-nav {
position: fixed;
bottom: 0;
width: 100%;
background-color: #242424;
}
.site-nav ul {
display: flex;
justify-content: center;
padding: 0px 12px;
}
.site-nav li {
flex-grow: 1;
}
.site-nav a {
display: flex;
flex-direction: column;
text-align: center;
padding: 12px 0px 24px;
}
.site-nav img {
height: 40px;
}
Now the navigations are correctly placed on top and bottom, but they covering the content. Also the paragraphs can use some more margin. Let’s fix that by:
.content {
padding: 48px 12px 110px;
}
p {
margin-bottom: 8px;
}
Creating the desktop layout
This looks ok now for mobile, let’s fix the desktop version. We pick a small desktop as minimal width 768px which is 48 rem. Everything above that we consider desktop and gets that styling. Let’s do that with some media queries.
-
The site-header get
position:sticky
so we can easily put that on on top and stays there. -
We need to reset some
position:fixed
of the header and nav by giving itposition:static
. -
The texts a bit bigger, hide the icons and give more room with padding.
@media (min-width: 48rem) {
.container {
max-width: 960px;
margin: 0 auto;
}
.site-header {
position: sticky;
top: 0px;
display: flex;
background-color: #242424;
font-size: 1.4rem;
align-items: center;
justify-content: space-between;
padding: 0px 12px;
}
.site-nav { font-size: 1rem; }
.site-nav ul { padding: 0px; }
.site-nav,
.logo-container {
position: static;
width: auto;
}
.site-nav {
display: flex;
justify-content: center;
}
.site-nav img {
display: none;
}
.site-nav a {
padding: 4px 8px;
}
.content {
padding: 12px;
}
}
Now the header is fixed, the nav elements are right aligned. It should look like below, open it in a new tab and resize screen to see the two different layouts.
If you think this is already nice then you can stop reading. If you want to save some space by hiding the menu when we scroll down, stick around because we use some javascript for it.
Add Scroll-Based Header Behavior
Let’s make the header disappear on scroll down and reappear on scroll up.
- create a new file called
scrollHeader.js
and add a basic method to check if it’s working:
export const scrollHeader = (element) => {
console.log("node", element);
};
2. inside src/main.js
import this method, and let’s add the selector to give the element where we will work with:
import { scrollHeader } from "./scrollHeader.js";
scrollHeader(document.querySelector(".site-header"));
If you inspect the browser console (right mouse click in the browser > inspect element > press console). You should see the output with the found element:
3. Add scroll direction logic. Whenever the user scrolls down, we want to hide the menu. When the user scrolls up it should show the menu again. First we need to make sure that we scroll, and the direction. To verify this works, we log it:
export const scrollHeader = (element) => {
console.log("node", element);
let direction = "";
let lastY = null;
const onScroll = () => {
const { scrollY } = window;
direction = scrollY > lastY ? "down" : "up";
console.log("scroll", direction);
lastY = scrollY;
};
window.addEventListener("scroll", onScroll, { passive: true });
};
4. Add CSS tranfsforms. If everything is correct you should see the initial log, and the scroll logs when you scroll. Now we want to hide the menu when we scroll down. We have 2 stages; visible and hidden. We already have the first visible stage, but since we want to use the position (the transform position) we need to give it a startPosition. Let’s do that in the css for both the menu items by adding the following into style.css
:
.logo-container, .site-nav {
transform: translateY(0px);
}
Whenever we scroll down we add a class to the header. With this class we can move the menu. Let’s call it hide-menu
and add some positions. Since we want to top transition to the top and the bottom we give both elements different end (hidden) positions in style.css
:
.hide-menu .logo-container {
transform: translateY(-100%);
}
.hide-menu .site-nav {
transform: translateY(100%);
}
5. Toggle class in JS. Now we need to the class name hide-menu
on the correct place, let’s do that in the javascript by adding a new const with the className and toggle it on the header based on scroll direction:
export const scrollHeader = (element) => {
console.log("node", element);
const className = "hide-menu";
let direction = "";
let lastY = null;
const onScroll = () => {
const { scrollY } = window;
direction = scrollY > lastY ? "down" : "up";
element.classList.toggle(className, direction === "down");
lastY = scrollY;
};
window.addEventListener("scroll", onScroll, { passive: true });
};
6. Add transition. The menu goes in and out, but there is now animation or transition. That’s easily fixed by adding transition to the place where we gave the initial position:
.logo-container, .site-nav {
transform: translateY(0px);
transition: transform ease-in 0.3s;
}
7. Add scroll offset. We see now the menu in and out on scroll. It only goes very fast, after 1 pixel scrolling of the top it already goes away. We could change that by for example take the top header height, and take height as amount of scroll in order to take in. If we inspect the element we can see the header is 48 pixels height. Let’s use that as offset, and adjust our code by adding a new variable called isPastOffset
. Whenever the current y is higher than the offset, the isPastOffset
is true.
export const scrollHeader = (element) => {
console.log("node", element);
const className = "hide-menu";
const offset = 48;
let direction = "";
let lastY = null;
let isPastOffset = false;
const onScroll = () => {
const { scrollY } = window;
isPastOffset = scrollY > offset;
direction = scrollY > lastY ? "down" : "up";
element.classList.toggle(className, isPastOffset && direction === "down");
lastY = scrollY;
};
window.addEventListener("scroll", onScroll, { passive: true });
};
8. Make it dynamic. The scroll offset works, but whenever we change the size of the header we need to update the javascript code, and that’s not someting we want. If we open the console and inspect the node
log, and hover over the header tag, you can see there is not height given.
Only if we expend the node and hover over the logo-container
element we see the correct height we need; 48.
How can we define which element we want to get because there are more? We could use a data
attribute for this. Let’s add data-scroll-anchor
to the div with the class logo-container
<div class="logo-container" data-scroll-anchor>
<a href="#" class="logo" aria-label="Homepage">
<img src="/vite.svg" alt="Vite logo" />
<span>Site name</span>
</a>
</div>
Then replace the hardcoded offset by the following code:
const offsetElement = element.querySelector("[data-scroll-anchor]");
if (!offsetElement) {
console.warn("no [data-scroll-anchor] has been found");
}
const offset = offsetElement?.getBoundingClientRect().height || 0;
9. Handle bottom scroll jitter on mobile. First we will try to find the element with the data-scroll-anchor
attribute. If it found it it will return the height of that, and otherwise 0. If you test the code out if will run fine in the browser, even if you put on mobile emulation. However, trying it on the mobile by going to the same adress you will see it will work correct until we’ve reached the bottom, then it will jitter a bit. This is happening because on mobile you have this paper fold effect, and scroll a bit further then on desktop. We can fix by checking if the page is already at the bottom and don’t change the direction:
const isAtBottom =
scrollY + window.innerHeight >= document.documentElement.scrollHeight;
if (!isAtBottom && lastY !== null) {
direction = scrollY > lastY ? "down" : "up";
}
10. Disable scroll behavior on desktop. Now if we refresh on mobile the scrolling works, even at the bottom. If we scroll down and refresh the menu now also opens so the user can navigate without scrolling. Mobile wise everything should work, but if we resize our screen to desktop view and scroll, the scroll logic is being initated and the menu looks off. For desktop views, we don’t need this logic so let’s disable it for those screen size. Instead of listening to an expsensive browser resize event we can watch the media query for changes:
const mediaQuery = window.matchMedia("(min-width: 48rem)");
let isDisabled = mediaQuery.matches;
const onMediaChange = (event) => {
isDisabled = event.matches;
if (event.matches) {
element.classList.remove(className);
}
};
const onScroll = () => {
if (isDisabled) return;
const { scrollY } = window;
//... rest of code
};
mediaQuery.addEventListener("change", onMediaChange);
This code will check if it’s inside the media query. If not it will be set isDisabled
to true
and it won’t do anything on scroll. We can test this out and most should work but there’s one thing that’s still not correct; when scrolling down on mobile and resize to desktop we can see the menu quickly transition. We can disable this by disabeling the transition for desktop in the css:
@media (min-width: 48rem) {
/* rest of code */
.logo-container, .site-nav {
transition: none;
}
}
Cleaning up the code
We are done! If you want we can clean some code up, right now we have some hardcode classNames, and selectors we can make that configurable, so next time you use this and want to change you don’t need to change any javascript. Also when listening to events like scroll or media queries with addEventListeners, it’s a good practice to clean these up when you don’t need them. Let’s make the code be able to cleanup, and set some config:
export const scrollHeader = (
element,
{
className = "hide-menu",
offsetSelector = "[data-scroll-anchor]",
mediaQueryMatch = "(min-width: 48rem)",
} = {}
) => {
const offsetElement = element.querySelector(offsetSelector);
if (!offsetElement) {
console.warn(`no ${offsetSelector} has been found`);
}
const mediaQuery = window.matchMedia(mediaQueryMatch);
// ... rest of code
return () => {
window.removeEventListener("scroll", onScroll);
mediaQuery.removeEventListener("change", onMediaChange);
element.classList.remove(className);
};
};
Now all the hardcoded options are default values, and if we ever want to change some options we don’t need to change the javascript anymore. For example we can use it like:
const scrollHeaderCleanup = scrollHeader(
document.querySelector(".site-header"),
{
className: "cutstom-hide-menu",
offsetSelector: "[data-scroll-anchor]",
mediaQueryMatch: "(min-width: 60rem)",
}
);
// unmount demo
setTimeout(() => {
console.log('destroy the component');
// cleanup, do this when the component is unmounted
scrollHeaderCleanup();
}, 1000);
Be aware that when changing the mediaQueryMatch
or className
you also need to change the values of your css. The cleanup method you can fire when the component get’s unmounted. For demo purposes I showed it above with a timeout. To be clear all the config options are not required, so you don’t need to update this and can still use the scrollHeader like:
scrollHeader(document.querySelector(".site-header"));
Wrapping up
You now have a fully responsive header that adapts to screen size and scroll direction.
-
✅ Works on mobile and desktop
-
✅ Keeps navigation accessible
-
✅ No extra libraries needed
If you have any questions or comments, please let me know!
- 🔗 Live Demo:
View on GitHub Pages - 🔗 CodeSandbox:
- 🔗 Source code github:
View on GitHub