Performance · 2 min read · 21 October 2026

A shrinking sticky header with zero JavaScript

The header sticks, shrinks its logo and drops the tagline as you scroll, all in CSS with scroll-driven animations. Browsers without support get the finished header instead of a scroll listener.

A cartoon hand dragging a browser scrollbar while the page header shrinks, next to a bin holding a crossed-out paper
0
scroll listeners behind the header
4
keyframe animations driven by the scroll position
10–30vh
the scroll range where the header changes

01 · The usual way

Most shrinking headers are a scroll listener.

Listen to scroll, read scrollY, set a class or a state, and let the framework re-render. It works, but it runs on the main thread on every scroll event. On a page that's busy hydrating, that's exactly where you don't want extra work.

02 · The CSS way

Tie the animation's progress to the scroll position instead of time.

animation-timeline: scroll() makes the page scroll the clock. animation-range says where the animation starts and ends. Between 10vh and 30vh of scroll, the menu gets a background and a shadow, the logo shrinks, and the tagline fades out. The same trick drives a parallax scale on the hero over the first 100vh.

@keyframes stick-menu {
  to { padding-block: 1rem; background: #fff; box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
}
@keyframes shrink-logo {
  to { width: 150px; transform: translate(0, 0.25rem); }
}

@supports (animation-timeline: scroll()) {
  .menu {
    animation: stick-menu 1ms linear forwards;
    animation-timeline: scroll();
    animation-range: 10vh 30vh;
  }
  .logo-full {
    animation: shrink-logo 1ms linear forwards;
    animation-timeline: scroll();
    animation-range: 10vh 30vh;
  }
}

03 · The order trap

Write animation-timeline after the animation shorthand.

The shorthand resets animation-timeline to its default. Put the timeline first and the shorthand quietly wipes it, so the header stops responding to the scroll. The 1ms duration is only a placeholder that the shorthand needs. The scroll decides progress, not time. forwards keeps the end state once you scroll past the range.

04 · The fallback is the finished state

No support means no animation, not a JavaScript polyfill.

Inside @supports not (animation-timeline: scroll()), the animated header simply gets the end values: compact logo, solid background, no tagline. It looks like the header after you've scrolled. Nothing breaks and nothing extra loads.

@supports not (animation-timeline: scroll()) {
  .menu.animated { padding-block: 1rem; background: #fff; box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
  .logo-full.animated { width: 150px; }
}

05 · What I'd add

Reduced motion.

The header changes follow your own scroll, so they feel like direct manipulation. The hero's parallax scale is the kind of motion some people turn off, and that project doesn't check for it yet. Next time I'd wrap the parallax in @media (prefers-reduced-motion: no-preference).

Next storyA CMS for editors who aren't the developers