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).
