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
- Animating Sticky Headers on Scroll Direction Change — direction-aware show/hide using a lightweight JS flag paired with CSS transitions
Syntax reference
The four properties that control scroll-driven header behaviour:
/* 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
-
animationshorthand does not resetanimation-timeline. Insideprefers-reduced-motion: reduce, settinganimation: noneleaves the scroll timeline active. The animation plays at effectively zero duration, teleporting to its end state on the first scroll tick. Always pair it withanimation-timeline: autooranimation-timeline: noneexplicitly. -
contain: paintorcontain: strictbreaks view-transition snapshots. If you plan to use the View Transitions API to animate the header during route changes, usecontain: layout styleonly.paintcontainment prevents the browser from reading pixels outside the element boundary during snapshot capture. -
animation-fill-mode: bothis required to hold the condensed state. Without it, the header snaps back to thefromstate when the scroll position is between 0 andanimation-range-start, and also snaps back when the user scrolls pastanimation-range-end. In the shorthandanimation: name linear both, thebothkeyword setsfill-mode. -
padding-blockanimation causes CLS. Animated padding changes the element’s layout box on every frame. DevTools Performance panel will showLayoutevents during scroll. Replace withtransform: scaleY()on an inner element or withmin-heighton an outer wrapper that is never itself animated. -
backdrop-filteron Firefox triggers main-thread paint. Firefox (as of 128) does not always promotebackdrop-filterto the compositor thread. If targeting Firefox, verify with the Rendering panel (“Paint flashing”) and provide abackground-colorfallback with no blur. -
z-indexandscroll-timelineinteraction with@keyframesthat animatez-index. Animatingz-indexfrom a scroll timeline causes stacking context recalculation on the main thread every frame. Never animatez-index— toggle it with a class change after the scroll animation ends. -
Named scroll timelines must be defined on a scrolling ancestor. If you use
scroll-timeline-nameon an element, the named timeline is only visible to that element’s descendants, not to elements outside its subtree. Move the declaration to:rootor the nearest shared ancestor.
Performance checklist
Related
- Animating Sticky Headers on Scroll Direction Change — full direction-aware implementation
- Building Scroll Progress Indicators — sibling pattern using the same
scroll(root)timeline for a reading progress bar - The Rendering Pipeline for Scroll Animations — how style/layout/paint/composite phases determine which properties are safe to animate
- Understanding the CSS Scroll Timeline API — spec-level reference for
scroll(),view(), andanimation-rangesyntax - Browser Support and Progressive Enhancement —
@supportsguard patterns and fallback strategies