A sticky header is a navigation bar that stays at the top of the screen as the page scrolls. My favourite variation is the "smart" one: it slides out of the way when you scroll down, then reappears the instant the user starts scrolling back up. This behaviour hands vertical space back to the content for reading while keeping navigation one flick away. In this guide I'll build the pinned bar first with pure CSS, then add the hide-on-scroll behaviour that detects scroll direction, step by step.
The simplest approach: position: sticky
In most cases you don't even need JavaScript. position: sticky keeps the element in normal flow but pins it once it reaches the threshold you set. You must give it a top value; without one it never sticks.
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
}
That's it. The bar starts in the page flow, fixes itself when it touches the top edge, and the rest of the content scrolls beneath it. The advantage of sticky over fixed is that the element occupies real space at first: it doesn't overlap the content below it, so you don't need to add a compensating padding-top.
When sticky isn't working, it's almost always one of two reasons: either you didn't supply a top threshold, or a parent element has overflow: hidden / auto / scroll. The second one silently disables sticky, because the element is now bound to that container's scroll context.
Hide and show based on scroll direction
For the "smart" behaviour we actually want, we need to know the scroll direction. The logic is simple: on every scroll, compare the current scrollY value with the previous one. If it grew, the user is going down; if it shrank, they're going up. Going down we slide the bar up to hide it; going up we bring it back.
.site-header {
position: sticky;
top: 0;
transition: transform 0.25s ease;
}
.site-header.hidden {
transform: translateY(-100%);
}
I use transform: translateY(-100%) for performance: transform and opacity changes are handled on the browser's compositor layer, meaning they animate at a smooth 60 fps without triggering layout or paint. Chasing the same effect by changing top or height is far more expensive.
Writing the scroll listener correctly
The scroll event fires dozens of times per second. If you read and write the DOM on every fire, the page stutters. The fix is to defer the work to the next paint frame with requestAnimationFrame and use a flag to prevent it running more than once per frame.
const header = document.querySelector('.site-header');
let lastScroll = 0;
let ticking = false;
function update() {
const y = window.scrollY;
// Always show when at the very top
if (y <= 0) {
header.classList.remove('hidden');
} else if (y > lastScroll && y > 80) {
// Scrolling down past the threshold -> hide
header.classList.add('hidden');
} else if (y < lastScroll) {
// Scrolling up -> show
header.classList.remove('hidden');
}
lastScroll = y;
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
}, { passive: true });
A few small details make this code usable:
{ passive: true }: tells the browser "this listener won't callpreventDefault", so it can scroll more smoothly without blocking.- The
y > 80threshold: prevents the bar flickering in the first few pixels at the top of the page. Hiding doesn't begin until the user has scrolled down a bit. - Always show at the top: the
y <= 0check guarantees the header is visible when you return to the page top.
Changing the look once it sticks
Another common request: the header is transparent/large at the top of the page, then gains a background and shrinks once scrolled. We do this by toggling a class based on whether it's "stuck". A clean way to detect that without polling is IntersectionObserver: place a 1px sentinel element just above the header, and when it leaves the screen the header is stuck.
const sentinel = document.querySelector('#header-sentinel');
const io = new IntersectionObserver(([entry]) => {
header.classList.toggle('stuck', !entry.isIntersecting);
}, { threshold: 0 });
io.observe(sentinel);
.site-header.stuck {
box-shadow: 0 2px 12px rgba(0,0,0,.08);
backdrop-filter: blur(8px);
}
The beauty of IntersectionObserver is that it does this without calling getBoundingClientRect() on every scroll event, letting the browser schedule the work itself — which is far more efficient.
Accessibility and mobile
Sticky headers cause accessibility problems when built carelessly. Don't overlook these:
- Anchor links: clicking an in-page
#sectionlink can land the target underneath the fixed header. Addingscroll-margin-top: 80px;to the target sections fixes this. - Motion preference: disable the
transitioninside@media (prefers-reduced-motion: reduce), so the bar changes instantly for users who are sensitive to motion. - Keyboard focus: when links inside the bar receive focus via
Tabwhile it's hidden, the bar should become visible. Because we hide withtransformrather thandisplay: none, this already works. - Height on mobile: don't let the header eat too much vertical space on small screens; the hide/show behaviour is especially valuable here because it gives the whole screen back while reading.
@media (prefers-reduced-motion: reduce) {
.site-header { transition: none; }
}
section[id] { scroll-margin-top: 80px; }
Frequently Asked Questions
What's the difference between position: sticky and fixed?
fixed removes the element from normal flow entirely and always pins it to the viewport; it overlaps the content below, so you need a manual padding-top. sticky starts in flow, only pins on reaching the threshold, and releases when its container ends. For a full-page header, sticky is usually cleaner.
The header flickers/jumps while scrolling — why?
Usually two reasons: either you haven't throttled the scroll event with requestAnimationFrame, or there's no hide/show threshold for the first pixels at the top. A threshold like y > 80 plus rAF framing removes that jitter.
Can hide/show be done with CSS alone?
Not quite. position: sticky handles the pinning in CSS, but the "hide when going down, show when going up" behaviour requires knowing the scroll direction, and a small piece of JavaScript is still the most reliable way to do that today.
Is your header bar not behaving the way it should? We can make sticky headers, dropdown menus and layering (z-index) issues smooth and accessible together. If you have a project, get in touch with me.