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 scrolled direction (@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.

Division of Work in a Headroom HeaderFour boxes. A passive scroll listener coalesced to one read per frame compares scroll positions to find the direction. A tolerance ignores movements under ten pixels. An IntersectionObserver sentinel at the top of the page forces the header visible while the page is near its start. CSS transitions the header's transform when the hidden class toggles.Scroll listenerpassive, one read perframeToleranceignore moves under 10pxTop sentinel (IO)near top: always showCSS transformslide via class toggle

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

TypeScript
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

CSS
.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 */
TypeScript
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.

Header State During a Reading SessionA timeline of header visibility while reading. Near the top the sentinel keeps the header shown. Scrolling down past the sentinel hides it. A small upward jitter below the tolerance leaves it hidden. A deliberate scroll up shows it. Tabbing into the header shows it through focus-within regardless of direction.Reading a long articlescrollnear topreading downjitterscroll upreading downheadershownhiddenshownhidden0s10s20s30s40s50s60s

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-within on the header keeps it visible while any of its controls has focus, and scroll-padding keeps 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.

When the Header Must Be VisibleA grid of situations and the header state each requires. Near the top of the page the header is shown. Scrolling down past the tolerance hides it. Scrolling up past the tolerance shows it. Focus inside the header always shows it. An anchor jump leaves it shown and lands content below it using scroll padding.HeaderHandled byNear the topshownIO sentinelScrolling down (> tolerance)hiddenscroll listenerScrolling up (> tolerance)shownscroll listenerFocus inside headershownCSS :focus-withinAnchor jumpshown, content belowscroll-padding + hashchange

Verification Steps

  • Record a trace while scrolling; the only per-frame work should be a tiny update call.
  • 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 top or margin. It triggers layout every frame; use transform.

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.


↑ Back to Scroll-Driven Effects & Sticky Headers