Sticky Header & Navigation Transitions

position: sticky and animation-timeline: scroll(root) solve different sub-problems and do not conflict. The browser applies the sticky constraint and evaluates the scroll timeline independently — the header stays pinned at the top while its visual appearance (padding, blur, opacity) morphs as the user scrolls. This replaces JavaScript scroll listeners with a declarative CSS-only condensing pattern, pushing the animation off the main thread entirely and reducing scroll-jank risk to near zero. The pattern lives inside the Scroll-Driven & View Transition Implementation Patterns family of techniques.

Pages in this section


Sticky constraint and scroll timeline as independent browser mechanisms Two parallel vertical tracks show the sticky constraint (top:0 clamp) and the scroll timeline (0–120px animation-range) feeding into the same .sticky-nav element simultaneously without interference. position: sticky top: 0 animation-timeline: scroll(root) Clamps offset to viewport top edge (layout phase) Interpolates opacity / blur (composite phase) .sticky-nav pinned + visually condensed animation-range: 0px 120px

Syntax reference

The four properties that control scroll-driven header behaviour:

Header states across the scroll range Four bands of scroll progress. At the very top the header is transparent and full height. Through the first hundred pixels it compresses and gains a background. Beyond that it holds a compact state. On upward scroll it returns, which is a direction change rather than a position change. what the header should express at each point of the scroll 0px transparent, full height 0 → 100px compress + background in > 100px compact, holds steady scrolling up reveal again scroll position — expressible with a scroll timeline and a range direction, not position Why the last band is different The first three states are functions of scroll offset, so a scroll timeline expresses them exactly. "Reveal on upward scroll" is a function of scroll DIRECTION, which no timeline reports — that state still needs a small script, or a design that drops the direction rule.
/* animation-timeline: scroll( [scroller] [axis] )
   scroller: root | nearest | self | <custom-ident>
   axis:     block | inline | x | y           (default: block)
   default scroller when omitted: nearest      */

.sticky-nav {
  animation-timeline: scroll(root);       /* track the root scroller */
  animation-range: 0px 120px;            /* play over the first 120px of scroll */
  animation-fill-mode: both;             /* hold end state beyond the range */
  animation-play-state: running;         /* or paused to freeze the timeline */
}

scroll(root) selects the document’s root scroll container. scroll(nearest) selects the nearest scrollable ancestor — use that when the header is inside a custom scroll container rather than on the page itself.

animation-range shorthand maps to animation-range-start and animation-range-end. Values may be bare lengths (0px 120px), percentages of the scroll container’s scrollable overflow, or keyword-length pairs from the animation-range entry and exit keywords (entry 0% exit 100%).

Minimal working example

A fully self-contained condensing header with no external dependencies:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Sticky header demo</title>
  <style>
    /* — reset & scroll room — */
    * { box-sizing: border-box; margin: 0; }
    body { min-height: 300vh; font-family: system-ui, sans-serif; }

    /* — condensing keyframe (compositor-safe properties only) — */
    @keyframes header-condense {
      from {
        opacity: 0.92;
        backdrop-filter: blur(0px);
        /* padding-block intentionally omitted — triggers layout */
      }
      to {
        opacity: 1;
        backdrop-filter: blur(14px);
      }
    }

    /* — sticky nav — */
    .sticky-nav {
      position: sticky;
      top: 0;
      z-index: 100;
      padding: 1.25rem 2rem;
      background: rgb(255 255 255 / 0.85);
      border-bottom: 1px solid rgb(0 0 0 / 0.08);

      /* scroll-driven timeline */
      animation: header-condense linear both;
      animation-timeline: scroll(root);
      animation-range: 0px 120px;

      /* promote to its own compositor layer */
      will-change: opacity, backdrop-filter;
      contain: layout style;
    }

    /* — reduced-motion reset — */
    @media (prefers-reduced-motion: reduce) {
      .sticky-nav {
        animation: none !important;
        animation-timeline: auto !important; /* must reset separately */
        backdrop-filter: blur(10px);
      }
    }

    .content { padding: 2rem; }
  </style>
</head>
<body>
  <header class="sticky-nav">My Site</header>
  <main class="content">
    <p>Scroll down to see the header condense.</p>
  </main>
</body>
</html>

position: sticky and animation-timeline are evaluated in separate browser phases (layout vs. composite) so they do not interfere. The sticky constraint pins the element; the scroll timeline drives the visual interpolation.

animation-range / timeline scoping

animation-range: 0px 120px locks the entire condensing transition to the first 120 pixels of root scroll. The header reaches its fully condensed state at 120px and holds that state for the rest of the page because animation-fill-mode: both preserves the to keyframe values beyond the range end.

/* Narrow the range further if content begins 60px below the fold */
animation-range: 60px 180px;

/* Or express as a percentage of total scrollable overflow */
animation-range: 0% 10%;

/* Named scroll timeline for a custom scroller (not root) */
@scroll-timeline main-scroll {
  source: selector(#main);
  axis: block;
}
.sticky-nav {
  animation-timeline: --main-scroll;
  animation-range: 0px 80px;
}

Range asymmetry: the range applies to playback in both directions. Scrolling back to 0px smoothly reverses the condensing, which is usually the desired behaviour. If you need an asymmetric effect (condense fast, expand slow), use a JS-toggled class and a CSS transition with separate transition-duration values per direction.

Compositor-safe properties

Animating the wrong properties forces the rendering pipeline back to the main thread:

Property Thread Notes
opacity Compositor Safe — no layout or paint cost
transform Compositor Safe — use scaleY() for height-like shrink
backdrop-filter Compositor Safe on Chrome/Safari; causes repaint on some Firefox builds
filter Compositor (Chrome/Edge) Causes paint on Safari — test on device
background-color Main thread Triggers paint — avoid for per-frame changes
padding / padding-block Main thread Triggers layout — causes CLS risk
height Main thread Triggers layout — use transform: scaleY() instead
border-width Main thread Triggers layout
box-shadow Main thread (paint) Prefer filter: drop-shadow() for compositor path
clip-path (simple) Compositor Safe on Chrome 117+; paint on Firefox

will-change: transform, opacity promotes the element to its own compositor layer before the animation begins, eliminating the one-frame promotion jank that otherwise occurs on first scroll. Set will-change only on elements you know will animate — overusing it wastes GPU memory.

contain: layout style (without paint) scopes layout recalculation and style resolution to the header’s subtree without preventing the View Transitions API from snapshotting the element. Adding paint or using contain: strict breaks view-transition snapshots.

Common implementation patterns

Pattern 1: Blur-only condensing (zero layout cost)

The safest pattern — animates only compositor properties:

@keyframes header-blur-in {
  from { backdrop-filter: blur(0px); opacity: 0.9; }
  to   { backdrop-filter: blur(16px); opacity: 1;  }
}

.sticky-nav {
  position: sticky;
  top: 0;
  /* fixed visual height — no animated padding */
  padding-block: 1rem;
  background: rgb(240 235 255 / 0.8); /* dusty plum tint */
  animation: header-blur-in linear both;
  animation-timeline: scroll(root);
  animation-range: 0px 100px;
  will-change: opacity, backdrop-filter;
}

The header height stays constant so there is no CLS. Blur and opacity animate on the compositor thread.

Pattern 2: Transform-based size reduction (layout-free shrink)

Simulate a shrinking header without touching height or padding:

@keyframes nav-inner-shrink {
  from { transform: scaleY(1); }
  to   { transform: scaleY(0.7); transform-origin: top center; }
}

/* Reserve space with a fixed-height outer wrapper */
.sticky-nav-wrapper {
  position: sticky;
  top: 0;
  height: 80px; /* fixed — never animated */
  z-index: 100;
}

.sticky-nav-inner {
  height: 100%;
  transform-origin: top center;
  animation: nav-inner-shrink linear both;
  animation-timeline: scroll(root);
  animation-range: 0px 120px;
  will-change: transform;
}

transform: scaleY() on the inner element visually shrinks the header without recalculating layout. The outer wrapper’s fixed height prevents content from reflowing.

Pattern 3: Direction-aware show/hide

CSS has no native scroll-direction value. The production pattern pairs a tiny JavaScript detector with CSS transitions. JavaScript sets data-scroll-dir on :root; CSS reads it via attribute selectors, keeping all interpolation declarative:

// Direction detector — sets a data attribute, nothing else
let lastY = 0;
let ticking = false;

window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      const dir = window.scrollY > lastY ? 'down' : 'up';
      if (dir !== document.documentElement.dataset.scrollDir) {
        document.documentElement.dataset.scrollDir = dir;
      }
      lastY = window.scrollY;
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });
/* CSS owns the animation — JS only flips the attribute */
.sticky-nav {
  transition:
    transform 0.28s cubic-bezier(0.4, 0, 0.6, 1),
    opacity   0.28s ease;
}

[data-scroll-dir="down"] .sticky-nav {
  transform: translateY(-100%);
  opacity: 0;
}

[data-scroll-dir="up"] .sticky-nav,
:root:not([data-scroll-dir]) .sticky-nav {
  transform: translateY(0);
  opacity: 1;
}

Full implementation details are in Animating Sticky Headers on Scroll Direction Change.

Pattern 4: Scroll-progress logo swap

Swap a tall wordmark logo for a compact icon once the user has scrolled past the hero:

@keyframes logo-swap {
  0%   { opacity: 1; transform: scale(1); }
  45%  { opacity: 0; transform: scale(0.8); }
  55%  { opacity: 0; transform: scale(0.8); }
  100% { opacity: 1; transform: scale(1); }
}

/* Use view-transition-name so the swap can be captured as a view transition */
.logo-wordmark {
  view-transition-name: logo;
  animation: logo-swap linear both;
  animation-timeline: scroll(root);
  animation-range: 80px 160px;
}

Between 45% and 55% of the range both versions are invisible — JavaScript can swap the src or toggle a class inside that invisible window without a visual glitch.

Browser support and @supports guard

Feature Chrome Edge Safari Firefox
animation-timeline: scroll() 115 115 17.4 Behind flag (nightly 110+)
animation-range shorthand 115 115 17.4 Behind flag
backdrop-filter 76 79 9 103
position: sticky 56 16 13 32

position: sticky is universally supported. Gate only the scroll-timeline declarations:

/* Baseline: always-on sticky with static visual state */
.sticky-nav {
  position: sticky;
  top: 0;
  z-index: 100;
  backdrop-filter: blur(10px); /* active always, not animated */
  background: rgb(255 255 255 / 0.85);
}

/* Enhanced: scroll-driven condensing for supporting browsers */
@supports (animation-timeline: scroll()) {
  @keyframes header-condense {
    from { backdrop-filter: blur(0px); opacity: 0.92; }
    to   { backdrop-filter: blur(16px); opacity: 1;   }
  }

  .sticky-nav {
    animation: header-condense linear both;
    animation-timeline: scroll(root);
    animation-range: 0px 120px;
    backdrop-filter: blur(0px); /* override static value inside guard */
    will-change: opacity, backdrop-filter;
  }
}

For the JavaScript fallback when animation-timeline is unsupported, see the progressive enhancement strategy for scroll-driven animations.

Gotchas and failure modes

  1. animation shorthand does not reset animation-timeline. Inside prefers-reduced-motion: reduce, setting animation: none leaves the scroll timeline active. The animation plays at effectively zero duration, teleporting to its end state on the first scroll tick. Always pair it with animation-timeline: auto or animation-timeline: none explicitly.
Animating height moves the whole page Two versions of the same shrink. Animating height changes the space the header occupies, so every block below it is laid out again on each frame and the reading position drifts. Animating a transform inside a header of fixed height changes nothing about layout, so the page stays still. height animation header, shrinking content — moves up every frame content — moves up every frame layout runs on every frame transform inside a fixed height header, fixed box — inner bar scales content — does not move content — does not move compositor only, no layout The drift is worst on touch devices, where the header shrink and the scroll gesture fight each other and the page appears to stick. It also reports as layout shift in the field data.
  1. contain: paint or contain: strict breaks view-transition snapshots. If you plan to use the View Transitions API to animate the header during route changes, use contain: layout style only. paint containment prevents the browser from reading pixels outside the element boundary during snapshot capture.

  2. animation-fill-mode: both is required to hold the condensed state. Without it, the header snaps back to the from state when the scroll position is between 0 and animation-range-start, and also snaps back when the user scrolls past animation-range-end. In the shorthand animation: name linear both, the both keyword sets fill-mode.

  3. padding-block animation causes CLS. Animated padding changes the element’s layout box on every frame. DevTools Performance panel will show Layout events during scroll. Replace with transform: scaleY() on an inner element or with min-height on an outer wrapper that is never itself animated.

  4. backdrop-filter on Firefox triggers main-thread paint. Firefox (as of 128) does not always promote backdrop-filter to the compositor thread. If targeting Firefox, verify with the Rendering panel (“Paint flashing”) and provide a background-color fallback with no blur.

  5. z-index and scroll-timeline interaction with @keyframes that animate z-index. Animating z-index from a scroll timeline causes stacking context recalculation on the main thread every frame. Never animate z-index — toggle it with a class change after the scroll animation ends.

  6. Named scroll timelines must be defined on a scrolling ancestor. If you use scroll-timeline-name on an element, the named timeline is only visible to that element’s descendants, not to elements outside its subtree. Move the declaration to :root or the nearest shared ancestor.

Performance checklist

Frequently Asked Questions

Can a header hide on upward scroll using only CSS?

Not reliably. A scroll timeline reports position, not direction, and “hide when the user scrolls down, reveal when they scroll up” is a function of direction. Everything else a sticky header commonly does — compressing over the first hundred pixels, gaining a background, shrinking a logo — is a function of position and needs no script at all.

If the direction behaviour is a requirement, it needs a small amount of JavaScript that tracks direction and toggles a class; the visual change itself can still be pure CSS driven by that class.

Why does the content below the header jitter?

Because the header’s height is animating. Every frame changes how much vertical space it occupies, so everything below is laid out again and the reading position drifts. On touch devices this fights with the scroll gesture and can make the page feel like it is sticking.

Fix the header’s box and animate a transform on the contents inside it. The visual result is nearly identical, the layout never changes, and the reported layout shift drops to zero.

How do I stop the header flapping during momentum scrolling?

Require a minimum delta before changing state, and clamp the offset to the scrollable range. Momentum scrolling delivers events after the finger has lifted, rubber-band overscroll produces offsets outside the range that read as a direction reversal, and an anchor jump moves hundreds of pixels in a single event. A naive comparison against the previous offset misreads all three.

Does a hidden header cause accessibility problems?

It can. A header that is off-screen but still focusable creates a keyboard trap: tabbing moves focus into links the user cannot see, with no indication of where focus went. If the header is genuinely hidden, remove it from the tab order along with hiding it; if it must stay reachable, make it return on focus.

Compressing rather than hiding avoids the question entirely, which is one more argument for the position-based version over the direction-based one.

Should the header change state at a fixed pixel offset or a proportional one?

A fixed offset, in almost every case. The header is responding to “the reader has left the top of the page”, which is an absolute condition, not a proportional one. Tying it to a percentage of document height means the same gesture produces a different result on a short page and a long one, which reads as inconsistency rather than as responsiveness.

Around 80 to 120 pixels is the usual range: far enough that an accidental nudge does not trigger it, close enough that the change happens while the reader is still thinking about the act of scrolling.


Up: Scroll-Driven & View Transition Implementation Patterns