Tuning animation-range for Sticky Headers
Part of Scroll Timeline Scoping & Ranges in Modern View Transitions & Scroll APIs.
The problem
A sticky header that compacts as the page scrolls is a small effect with a large number of bad implementations. The common one attaches a scroll listener, compares scrollY against a threshold, and toggles a class — which snaps between two states at one pixel of scroll, runs a handler on every scroll event, and usually animates height or padding, forcing layout on the entire document below.
The result on a mid-tier device is a header that stutters while the rest of the page is already moving smoothly, which is a particularly unfortunate thing to have pinned at the top of the viewport.
Root cause: a threshold is a range with no width
The class-toggle approach models the state change as a crossing: below 100 pixels, expanded; above, compact. That is a step function, and a step function has no intermediate frames — the transition you see afterwards is a time-based animation triggered by the crossing, which has no relationship to how fast the user is scrolling.
A scroll timeline with an animation-range models the same change as an interval. Between 40 and 160 pixels of scroll, the header interpolates from expanded to compact; before and after, animation-fill-mode: both holds the end values. Progress is position, so the header’s state is always exactly right for where the page is, including mid-flick and during a scroll-up.
The second half of the fix is the property list. A compacting header usually wants to shrink, and the naive expression of “shrink” is height. Because a sticky header participates in layout, animating its height re-runs layout for everything after it, on every frame. Expressing the same change with transform on inner layers — scaling the logo, translating the nav, fading a subtitle — keeps the header’s box fixed and the whole effect on the compositor.
Step-by-step resolution
Production code pattern
/* Intent: a header that compacts across the first 160 pixels of scroll,
with the header's own box never changing size. */
:root { timeline-scope: --page-scroll; }
body { scroll-timeline: --page-scroll block; }
.site-header {
position: sticky;
inset-block-start: 0;
/* The box is a constant height; only its contents move. */
block-size: 72px;
}
.site-header__shade {
/* A pre-painted background layer that fades in — no colour interpolation. */
position: absolute;
inset: 0;
background: var(--color-surface);
box-shadow: 0 1px 0 rgb(30 27 75 / 12%);
opacity: 0;
animation: header-shade linear both;
animation-timeline: --page-scroll;
animation-duration: auto;
animation-range: 0 160px; /* the whole change happens in 160px */
}
.site-header__logo {
transform-origin: left center;
animation: header-shrink linear both;
animation-timeline: --page-scroll;
animation-duration: auto;
animation-range: 0 160px;
}
.site-header__tagline {
animation: header-hide linear both;
animation-timeline: --page-scroll;
animation-duration: auto;
animation-range: 0 90px; /* the tagline goes first */
}
@keyframes header-shade { to { opacity: 1; } }
@keyframes header-shrink { to { scale: 0.82; } }
@keyframes header-hide { to { opacity: 0; translate: 0 -4px; } }
/* Accessibility gate: no scroll-linked motion. The header renders in its
compact, legible state and stays there. */
@media (prefers-reduced-motion: reduce) {
.site-header__shade,
.site-header__logo,
.site-header__tagline {
animation: none;
animation-timeline: auto;
}
.site-header__shade { opacity: 1; }
.site-header__logo { scale: 0.82; }
.site-header__tagline { display: none; }
}
Rendering Impact:
opacity,scaleandtranslateon inner layers — composite only. The header’s own box is a fixed 72 pixels throughout, so nothing below it is ever re-laid out.
Staggering the ranges is what makes this read as designed rather than mechanical: the tagline finishes fading at 90 pixels while the logo is still shrinking through 160. Three elements, three ranges, one timeline — and no code deciding when each one starts.
Verification checklist
Constraints and trade-offs
- Scaling a logo scales its text, so a wordmark below roughly 0.8 scale starts to look thin; swap to a mark-only asset instead of scaling further.
- Ranges expressed in pixels are absolute, so a very short page may never reach the end of the range.
timeline-scopeon the root is broad; namespace the timeline identifier to avoid collisions with component-level timelines.- A fixed-height header cannot compact its box, which is the point — designs that genuinely need the content below to move up need a layout change, and that cannot be free.
- Sticky positioning plus a promoted child can create an implicit compositor layer for the header; check the Layers panel if memory matters.
Frequently asked questions
Why not use position: sticky with a sentinel and IntersectionObserver?
That pattern works and was the only option for years, but it costs an observer, a sentinel element and a class toggle, and it is binary. A range-driven header interpolates across a short stretch of scroll, which reads as deliberate rather than as a snap, and it needs no script.
Can I animate the header’s height this way?
You can, and you should not. Height is layout-tier, so every frame runs layout and paint for the whole document below the header. Animate a scale or translate on the inner content, or cross-fade two stacked layers of different heights.
What range values should I start with?
Convert the pixel distance you want into a percentage of the scroll range, or use explicit lengths in the range if your engine supports them. Between 80 and 200 pixels of scroll is enough for the change to feel intentional without lingering.
Hiding the header on scroll down, showing it on scroll up
The pattern above compacts a header that is always present. A related design — hide the header while scrolling down, reveal it on scroll up — cannot be expressed with a scroll timeline at all, and it is worth understanding why before trying.
A scroll timeline maps position to progress. “Scrolling down” is a property of the direction of change in position, which a timeline has no way to express: at scroll offset 900, progress is the same whether the user arrived there going up or going down. Any animation bound to that timeline is therefore identical in both directions.
Direction-aware behaviour needs a comparison between two samples, which means script. The cheap version is a scroll listener that compares the current offset with the previous one and toggles an attribute, with the actual motion still declared in CSS as a transform transition so the frames stay on the compositor:
let last = 0;
addEventListener('scroll', () => {
const y = window.scrollY;
if (Math.abs(y - last) > 8) { // ignore jitter
header.dataset.hidden = y > last && y > 120 ? 'true' : 'false';
last = y;
}
}, { passive: true });
Note the passive listener and the threshold: the handler does no reads, no writes beyond one attribute, and runs at most once per scroll event. Everything visual remains in the stylesheet, so the pattern keeps the compositor benefits even though the trigger is imperative.
Related
- Scroll Timeline Scoping & Ranges — the parent guide on ranges and name resolution
- Building a Scroll Progress Bar with scroll-timeline — the same timeline driving a different consumer
- Compositor-Only Property Optimization — why the header’s box must stay a constant size