Step-by-step
-
1
Make the Header Sticky with CSS
position: stickykeeps the header in the document flow (unlikefixed) and sticks it to the top of the viewport once the user scrolls past it.z-index: 50ensures it sits above page content.css.site-header { position: sticky; top: 0; z-index: 50; background: #fff; /* optional: soften the edge between header and content */ box-shadow: 0 1px 0 rgb(0 0 0 / .06); } -
2
Define the Hidden State with a CSS Class
Use a
.is-hiddenclass that slides the header out of view withtransform: translateY(-100%). Transform-based animation is GPU-composited — it does not trigger layout reflow and stays smooth at 60 fps even on mid-range devices.css.site-header { position: sticky; top: 0; z-index: 50; background: #fff; transition: transform 0.3s ease, box-shadow 0.3s ease; } .site-header.is-hidden { transform: translateY(-100%); } -
3
Track Scroll Direction in JavaScript
Store the previous scroll position in
lastScrollY. On each scroll event, compare the currentwindow.scrollYto it. Scrolling down means the new value is larger; scrolling up means it is smaller.javascriptconst header = document.querySelector('.site-header'); let lastScrollY = window.scrollY; window.addEventListener('scroll', () => { const currentScrollY = window.scrollY; const scrollingDown = currentScrollY > lastScrollY; header.classList.toggle('is-hidden', scrollingDown); lastScrollY = currentScrollY; }, { passive: true }); -
4
Add a Scroll Threshold to Prevent Flickering
Near the very top of the page, small scroll jitter can make the header flicker in and out. Only hide the header after the user has scrolled past a threshold (e.g. the header's own height).
javascriptconst header = document.querySelector('.site-header'); const THRESHOLD = header.offsetHeight; let lastScrollY = window.scrollY; window.addEventListener('scroll', () => { const currentScrollY = window.scrollY; // Never hide the header when near the top if (currentScrollY < THRESHOLD) { header.classList.remove('is-hidden'); lastScrollY = currentScrollY; return; } const scrollingDown = currentScrollY > lastScrollY; header.classList.toggle('is-hidden', scrollingDown); lastScrollY = currentScrollY; }, { passive: true }); -
5
Shrink the Header on Scroll with CSS
A complementary pattern: shrink the header's padding (and optionally its logo size) when the user scrolls down, to reclaim a few pixels while keeping the header visible. Toggle a
.is-scrolledclass instead of hiding it entirely.css.site-header { position: sticky; top: 0; z-index: 50; padding-block: 1.25rem; transition: padding 0.3s ease, box-shadow 0.3s ease; } .site-header.is-scrolled { padding-block: 0.5rem; box-shadow: 0 2px 12px rgb(0 0 0 / .1); } -
6
Experiment with Scroll-Driven Animations
Modern browsers support scroll-driven animations — a pure-CSS way to animate based on scroll position, with zero JavaScript. The example below shrinks the header's background-color as the user scrolls, using
animation-timeline: scroll(). Browser support is Chromium 115+ and Firefox 110+ — use it as progressive enhancement.css@supports (animation-timeline: scroll()) { .site-header { animation: shrink-header linear both; animation-timeline: scroll(); animation-range: 0px 80px; } @keyframes shrink-header { from { padding-block: 1.25rem; background: transparent; } to { padding-block: 0.5rem; background: #fff; box-shadow: 0 1px 8px rgb(0 0 0 / .08); } } } -
7
Handle Accessibility — Keep Focus Inside
When the header hides, interactive elements inside it (navigation links, a search button) become invisible but remain in the tab order. Add
inertto the header when it is hidden, and remove it when it reappears. This removes the element from both tab focus and screen reader announcement without removing it from the DOM.javascriptwindow.addEventListener('scroll', () => { const currentScrollY = window.scrollY; if (currentScrollY < THRESHOLD) { header.classList.remove('is-hidden'); header.removeAttribute('inert'); lastScrollY = currentScrollY; return; } const scrollingDown = currentScrollY > lastScrollY; header.classList.toggle('is-hidden', scrollingDown); header.toggleAttribute('inert', scrollingDown); lastScrollY = currentScrollY; }, { passive: true });
Tips & gotchas
- Always mark your scroll listener as <code>{ passive: true }</code> — this tells the browser the handler will never call <code>preventDefault()</code>, enabling scroll-performance optimisations.
- If your page has anchor links, a hidden sticky header will overlap the target. Compensate with <code>scroll-margin-top</code> on anchored elements: <code>:target { scroll-margin-top: 80px; }</code>.
- For menus opened inside the header (dropdowns, mobile nav), pause the hide-on-scroll logic while the menu is open — otherwise the header may vanish mid-interaction.
- <code>position: sticky</code> requires the parent not to have <code>overflow: hidden</code> or <code>overflow: auto</code> set — that clips the stickiness.
Wrapping up
A well-built sticky header is a two-part contract: CSS owns the animation (smooth, GPU-composited), and JavaScript owns only the state decision (hide or show). Keep the scroll handler lightweight and passive, add the threshold to eliminate flicker near the top, and handle the inert attribute to keep the experience accessible. The scroll-driven animation API will eventually replace the JS approach entirely — start with the CSS-first pattern now.