Animating Sticky Headers on Scroll Direction Change

Direction-aware header animation — reveal on scroll up, hide on scroll down — cannot yet be expressed in pure CSS because the CSS Scroll Timeline API has no native concept of scroll direction. The production pattern pairs a lightweight JavaScript direction detector with CSS transitions driven by a data- attribute, keeping visual interpolation on the compositor thread while limiting JavaScript to a single attribute toggle per direction change. For the complementary CSS-only condensing pattern (which does require no JavaScript), see the parent page on sticky header and navigation transitions.


Direction-aware header animation architecture A flow diagram: the scroll event fires a requestAnimationFrame callback that measures delta Y and writes data-scroll-dir to the root element. The CSS transition on .header-sticky reads that attribute and animates translateY and opacity on the compositor thread. scroll event (passive listener) requestAnimationFrame measure deltaY → flag data-scroll-dir= "up" | "down" on :root CSS transition translateY / opacity — compositor only Main thread Compositor thread

When to use this approach

Choose direction-aware animation when the header needs to reclaim vertical space while the user is reading (scroll down) and resurface instantly when they signal intent to navigate back (scroll up).

  • Use this pattern when your header is tall enough (≥48 px) that hiding it measurably increases content visibility on mobile viewports.
  • Use this pattern when scroll-timeline condensing alone (a CSS-only size reduction) is not enough — users still want the header gone entirely while reading long articles.
  • Prefer the pure CSS condensing pattern from sticky header and navigation transitions when a 50% size reduction is sufficient and you want zero JavaScript involvement.
  • Avoid this pattern for headers shorter than ~36 px or on pages where users rarely scroll more than one viewport height — the interaction cost of the hide/show cycle outweighs the gained space.
  • Avoid combining with animation-timeline: scroll() on the same properties being transitioned — only combine on different properties (see Step 3 below).

Implementation

Step 1 — Set the CSS foundation

Animate only compositor-safe properties. transform and opacity run entirely on the GPU without triggering layout or paint. Setting will-change upfront allocates the composited layer before the first direction change.

.header-sticky {
  position: sticky;
  top: 0;
  z-index: 100;
  /* Compositor-safe show/hide transition */
  transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
              opacity   0.3s ease;
  will-change: transform, opacity;
}

/* Visible state — scroll up or no direction yet */
:root:not([data-scroll-dir]) .header-sticky,
[data-scroll-dir="up"]       .header-sticky {
  transform: translateY(0);
  opacity: 1;
}

/* Hidden state — scroll down */
[data-scroll-dir="down"] .header-sticky {
  transform: translateY(-100%);
  opacity: 0;
}

/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
  .header-sticky {
    transition: none;
  }
}

translateY(-100%) is preferred over top: -64px because top forces a layout recalculation every frame. translateY is promoted to the compositor and never touches layout. See the rendering pipeline for scroll animations for a detailed breakdown of which properties trigger which pipeline phases.

Step 2 — Add the direction detector

JavaScript’s only job here is to read scrollY and write a single data- attribute when direction changes beyond the threshold. Everything visual stays in CSS.

(function () {
  let lastScrollY = window.scrollY;
  let ticking = false;

  function onFrame() {
    const currentScrollY = window.scrollY;
    const delta = currentScrollY - lastScrollY;

    // 15 px threshold: ignores iOS rubber-band bounce at page top/bottom
    if (Math.abs(delta) > 15) {
      document.documentElement.dataset.scrollDir =
        delta > 0 ? 'down' : 'up';
      lastScrollY = currentScrollY;
    }

    ticking = false;
  }

  window.addEventListener('scroll', function () {
    if (!ticking) {
      requestAnimationFrame(onFrame);
      ticking = true;
    }
  }, { passive: true });
}());

Key decisions:

  • { passive: true } — tells the browser this listener will never call preventDefault(), so the browser can start scrolling immediately without waiting for the callback to return.
  • requestAnimationFrame debounce — coalesces multiple scroll events per frame into one attribute write. The ticking flag prevents queuing more than one rAF at a time.
  • 15 px dead-zone — iOS Safari produces tiny negative scrollY values during elastic bounce at the top of the page. A threshold below ~10 px triggers false “scroll up” events during that bounce.

Step 3 — Combine with scroll-timeline condensing

A header can simultaneously condense (shrink padding and apply blur via animation-timeline: scroll()) and hide/show via direction detection. The key is that each mechanism must operate on a different CSS property — animation drives appearance properties, transition drives transform.

@keyframes header-condense {
  from {
    padding-block: 1.5rem;
    backdrop-filter: blur(0px);
    background-color: transparent;
  }
  to {
    padding-block: 0.75rem;
    backdrop-filter: blur(12px);
    background-color: oklch(15% 0.02 270 / 0.85);
  }
}

.header-sticky {
  position: sticky;
  top: 0;

  /* Condensing: driven by scroll-timeline — runs on compositor */
  animation: header-condense linear both;
  animation-timeline: scroll(root);
  animation-range: 0px 120px;

  /* Show/hide: driven by JS + CSS transition — also compositor */
  transition: transform 0.3s ease, opacity 0.3s ease;
  will-change: transform, opacity;
}

The animation shorthand and the transition property never compete because they target different CSS properties. Chrome’s Animations DevTools panel will show two separate timelines for the same element — one scrub-linked to scroll position, one time-based triggered by class changes.

For the @supports guard that prevents animation-timeline from causing layout issues in Firefox 114 and earlier, see browser support and progressive enhancement.

Step 4 — Wire view-transition-name for SPA routes

In a single-page application where the header persists across route changes, assigning view-transition-name lets the browser capture and morph the header’s state during navigation. This is especially useful when the header’s condensed/expanded state differs between routes.

.header-sticky {
  view-transition-name: site-header;
}
async function navigate(url) {
  if (!document.startViewTransition) {
    // Fallback: plain navigation
    await updateDOM(url);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateDOM(url);
    // Reset direction flag so header appears on new route
    delete document.documentElement.dataset.scrollDir;
  });

  await transition.finished;
}

Deleting data-scroll-dir inside the startViewTransition callback ensures the header is visible on the incoming route regardless of the direction it was hidden on the outgoing one. See SPA page-swap animations for the full navigation wiring pattern.

Verification

DevTools Performance panel

  1. Open DevTools → Performance → enable CPU throttle at 4×.
  2. Record ~5 seconds of rapid up-and-down scrolling.
  3. In the flame chart, Main thread should show scroll handlers as tiny slivers under 1 ms — not as long tasks.
  4. In the Frames view, every frame that contains a direction change should still complete within the 16.7 ms budget.
  5. Open the Layers panel (three-dot menu → More tools → Layers). The header should appear on its own composited layer after the first will-change is applied — it should not flash back to the root layer during direction transitions.

Animations panel assertion

Open DevTools → Animations. Trigger a direction change by scrolling down, then scroll up. Two entries should appear under the .header-sticky element:

  • A scrub animation representing the scroll-timeline condensing (if combined with Step 3).
  • A transition animation for transform and opacity with a 300 ms duration.

If you see a third animation named none or an animation on top or height, a rule elsewhere is competing — audit with getComputedStyle(header).transition.

Automated assertion (Playwright)

test('header hides on scroll down', async ({ page }) => {
  await page.goto('/');
  // Scroll down past the threshold
  await page.evaluate(() => window.scrollBy(0, 200));
  await page.waitForTimeout(400); // allow transition to complete
  const dir = await page.evaluate(
    () => document.documentElement.dataset.scrollDir
  );
  expect(dir).toBe('down');
  const transform = await page.locator('.header-sticky').evaluate(
    el => getComputedStyle(el).transform
  );
  // translateY(-100%) produces matrix(1, 0, 0, 1, 0, -<height>)
  expect(transform).toMatch(/matrix\(1, 0, 0, 1, 0, -\d/);
});

Edge cases and gotchas

iOS elastic bounce triggers false “scroll up” at page top. When scrollY is near zero and the user pulls down, the browser briefly reports a negative scrollY that snaps back. The 15 px delta threshold suppresses this, but if your content starts with a large hero image, consider also gating direction writes behind if (currentScrollY > 50) to skip the first 50 px of scroll entirely.

Header stays hidden after client-side hydration. If the server renders data-scroll-dir="down" (from a cached response), the header will be hidden before any JavaScript runs. Initialize the attribute to "up" in server-rendered HTML, or omit it entirely and let :root:not([data-scroll-dir]) keep the header visible until the first user scroll.

animation-timeline and transition on the same property conflict. If you accidentally apply both animation and transition to transform, the animation wins and the transition is silently ignored. Always ensure the two mechanisms target non-overlapping property sets.

will-change: transform promotes a new stacking context. This can cause z-index surprises: child elements of the header that use position: fixed will now be positioned relative to the header’s composited layer, not the viewport. Move such children outside the header element, or remove will-change and accept the brief paint cost on first direction change.

Keyboard-only users are stranded when the header is hidden. If focus moves to a link inside the hidden header (e.g. via Tab key), the header must reappear. Add:

document.querySelector('.header-sticky').addEventListener('focusin', () => {
  document.documentElement.dataset.scrollDir = 'up';
});

Browser-specific notes

Chrome 115+ — Full support for animation-timeline: scroll() and view-transition-name. The direction-detection pattern works as described. The Animations DevTools panel correctly shows scroll-linked and time-based animations on the same element separately.

Safari 18+ — animation-timeline: scroll() shipped in Safari 18 (October 2024). For Safari 17 and earlier, the @supports (animation-timeline: scroll()) guard in the condensing pattern degrades gracefully to a static compact state. The direction-detection transition pattern (Steps 1–2) works in all Safari versions that support CSS transitions (Safari 6.1+). Confirm with browser support and progressive enhancement.

Firefox 129+ — animation-timeline: scroll() shipped without a flag in Firefox 129 (August 2024). Earlier Firefox versions show the header in its from keyframe state (full padding, no blur) when @supports is guarded correctly. The direction-detection JavaScript and transition pattern work in all modern Firefox versions.

All browsers — { passive: true } is supported in Chrome 51+, Firefox 49+, and Safari 10+. On older browsers the event listener still works — it simply does not benefit from the passive scroll optimisation.