A hide-on-scroll-down header needs scroll direction, which observers do not report — so use a passive, frame-coalesced scroll listener that only compares positions and toggles one class past a tolerance, and let an IntersectionObserver sentinel handle the "at the top of the page" state that must always show the header.
Problem / Scenario Context
A news site wants more reading space on phones: the header should slide away while the reader scrolls down and reappear as soon as they scroll up, like many mobile browsers' own toolbars. The first version does everything in a scroll handler — reading scrollY, computing direction, measuring the header, toggling classes and writing inline transforms on every event — and shows up in traces as a steady stream of short tasks while scrolling. It also hides the header when the page loads scrolled to an anchor, and hides it while a keyboard user is tabbing through its links.
Direction is the one scroll property observers cannot provide, but most of the rest can be moved off the hot path. The Scroll-Driven Effects & Sticky Headers topic covers the related patterns; this page is the "headroom" header.
Mechanics Explanation
IntersectionObserver reports crossings: something entered or left a region. Scroll direction is a relationship between two positions over time, which no crossing captures. The honest options are:
- A scroll listener that reads
scrollY— a cheap read that does not force layout, since scroll position is kept up to date by the compositor — and compares it with the previous value. - Scroll-driven CSS: newer Chromium versions support scroll-state container queries including
scrolleddirection (@container scroll-state(scrolled: top)), which can hide and show a header with no script. Support is not universal yet.
Everything else is cheaper with observers or CSS:
- "At the top" (always show the header when the page is near its start): a sentinel at the top of the page with an
IntersectionObserver. - Hiding and showing: a class toggle that CSS transitions on
transform, never layout properties. - Tolerance: ignore direction changes until the scroll has moved a few pixels, to avoid flicker on trackpad jitter and rubber-band bounce.
Comparison Table: Implementation Choices
| Concern | Heavy version | Light version |
|---|---|---|
| Direction | scroll handler, every event | passive listener, one comparison per frame |
| At-top state | scrollY < headerHeight in handler |
sentinel + IntersectionObserver |
| Hiding | inline top/margin writes |
class toggle, CSS transform |
| Header height | measured every event | measured by ResizeObserver when it changes |
| Anchors / focus | hides on anchor jumps and focus | shows on focus, ignores programmatic scrolls |
| Reduced motion | ignored | instant show/hide |
Minimal Reproducible Example
let last = 0;
addEventListener('scroll', () => {
const y = window.scrollY;
const h = header.getBoundingClientRect().height; // layout read every event
header.style.top = y > last && y > h ? `-${h}px` : '0'; // layout-affecting write
last = y;
});
declare const header: HTMLElement;
Production-Safe Solution
.site-header {
position: fixed; inset: 0 0 auto 0; z-index: 40;
transition: transform 200ms ease-out;
}
.site-header.is-hidden { transform: translateY(-100%); }
.site-header:focus-within { transform: none; } /* never hide while focused */
@media (prefers-reduced-motion: reduce) { .site-header { transition: none; } }
body { padding-block-start: var(--header-h, 4rem); }
html { scroll-padding-block-start: var(--header-h, 4rem); } /* anchors land below the header */
export function headroom(header: HTMLElement, sentinel: HTMLElement, tolerance = 10): () => void {
let lastY = window.scrollY;
let nearTop = true;
let ticking = false;
const ac = new AbortController();
const update = (): void => {
ticking = false;
const y = window.scrollY;
const dy = y - lastY;
if (Math.abs(dy) < tolerance) return; // ignore jitter and bounces
header.classList.toggle('is-hidden', dy > 0 && !nearTop);
lastY = y;
};
addEventListener('scroll', () => {
if (!ticking) { ticking = true; requestAnimationFrame(update); }
}, { passive: true, signal: ac.signal });
// Near the top: always show, without per-scroll checks.
const io = new IntersectionObserver(([e]) => {
nearTop = e.isIntersecting;
if (nearTop) header.classList.remove('is-hidden');
});
io.observe(sentinel); // a zero-height element ~2 headers down
// Keep the header height in a custom property for padding and scroll-padding.
const ro = new ResizeObserver(([e]) => {
document.documentElement.style.setProperty('--header-h', `${e.borderBoxSize[0].blockSize}px`);
});
ro.observe(header);
// Programmatic jumps (anchors) should not be read as "scrolling down".
addEventListener('hashchange', () => { lastY = window.scrollY; }, { signal: ac.signal });
return () => { ac.abort(); io.disconnect(); ro.disconnect(); };
}
The scroll listener reads only scrollY and toggles a class, at most once per frame. The at-top rule, the header's height and focus handling are all outside the hot path: the observer, the ResizeObserver and a CSS :focus-within rule respectively. scroll-padding-block-start makes anchor jumps land below the fixed header, and resetting lastY on hashchange stops the jump from hiding it.
Accessibility Considerations
Headers that move are a known source of problems for keyboard and magnifier users:
- Focus must never be hidden. WCAG 2.4.11 (Focus Not Obscured) requires that a focused element is not entirely hidden by author-created content.
:focus-withinon the header keeps it visible while any of its controls has focus, andscroll-paddingkeeps focused content below it from being covered. - Motion should be optional. Under reduced motion, the header still hides and shows but without sliding.
- Do not hide on small movements. The tolerance protects users with tremors or imprecise trackpads from a flickering header.
- Screen readers are unaffected: the header remains in the DOM and reading order; only its visual position changes.
The browser's own collapsing toolbar on mobile behaves similarly; see tracking the address bar collapse on mobile Safari for keeping the two in step.
Verification Steps
- Record a trace while scrolling; the only per-frame work should be a tiny
updatecall. - Scroll up by a few pixels on a trackpad and confirm the header does not flicker.
- Tab into the header while it is hidden and confirm it appears.
- Follow an in-page anchor and confirm the header stays visible and does not cover the target.
- Emulate reduced motion and confirm the header appears instantly without sliding.
Common Mistakes to Avoid
- Measuring or writing layout in the scroll handler. Read
scrollY, toggle a class, nothing else. - No tolerance. Jitter and bounce make the header flicker.
- Hiding a header that contains focus. Keyboard users lose their place.
- Animating
topormargin. It triggers layout every frame; usetransform.
FAQ
Can IntersectionObserver detect scroll direction?
Not directly. It reports crossings, not positions over time. You can infer direction from a sentinel's boundingClientRect across entries, but only at crossings; continuous direction needs a scroll position comparison.
Is a passive scroll listener expensive?
Not if it only reads scrollY and schedules work once per frame. The expense in typical implementations comes from layout reads and writes inside the handler, not from the listener itself.
Can CSS do this without script?
In newer Chromium, scroll-state container queries can react to scroll direction, allowing a header to hide and show purely in CSS. Other engines do not support it yet, so use it as progressive enhancement.
Why use a sentinel for the at-top state instead of checking scrollY?
It removes a condition from the hot path and handles pages whose top content changes height, such as a dismissible banner, without recalculating a threshold.
How big should the tolerance be?
Five to fifteen pixels works well. Smaller values let trackpad noise through; larger ones make the header feel slow to respond when the reader genuinely scrolls up.
What happens on back-navigation with restored scroll?
The page loads mid-way with lastY equal to the restored position, so no direction is inferred until the reader scrolls, and the header starts visible. That is the expected behaviour.
Related
- Detecting When position: sticky Becomes Stuck — sticky rather than fixed headers
- Building a Sticky Header with IntersectionObserver — the base pattern
- Keeping Keyboard Focus Stable When Content Loads — focus under moving UI
↑ Back to Scroll-Driven Effects & Sticky Headers