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.

One scroll frame with a compacting headerThe listener plus height version pays layout for the entire document below the header on every frame.One scroll frame with a compacting headerframe budget 16.7 mslistener + heighthandlerlayoutpaint14.7 msrange + transform0.9 mshandlerlayoutpaintcomposite
The listener plus height version pays layout for the entire document below the header on every frame.

Step-by-step resolution

Building the range-driven headerPublish, hoist, subscribe, narrow — then gate.Building the range-driven header1Declare scroll-timeline on the scrolling element and timeline-scope on theroot.A fixed header can now resolve the name2Move every visual change onto transform, opacity or colour on innerlayers.The header's own box never changes size3Bind the animation with animation-timeline, duration auto and lineareasing.4Set an animation-range covering the stretch of scroll the change shouldoccupy.Roughly 80 to 200 pixels feels deliberate5Add the reduced-motion branch that applies a single state with nointerpolation.
Publish, hoist, subscribe, narrow — then gate.

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, scale and translate on 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.

Expressing a header change without touching layoutEvery layout-tier instinct has a compositor-safe equivalent.Expressing a header change without touching layoutLayout-tier instinctCompositor-safe equivalentShrink theheaderheight: 72px to 48pxFixed box, scale the contentsTighten thepaddingpadding animationtranslate the inner rowAdd a backgroundon scrollbackground-color transitionFade a pre-painted layerDrop a shadow inbox-shadow animationOpacity on the shade layerHide the taglineheight to 0opacity plus a small translate
Every layout-tier instinct has a compositor-safe equivalent.

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-scope on 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.