Scroll-triggered animations
animation-timeline: view() and scroll()
Chrome115
Edge115
Firefox-
Safari26
Features it needs
- Scroll-driven animationsLimited availability
Reveal-on-scroll libraries watch elements with an IntersectionObserver and add a class. animation-timeline replaces the observer with a timeline: view() ties an animation's progress to the element's position in the viewport, and scroll() ties it to a scroll container's offset. These run off the main thread, so they hold up under load in a way that a scroll listener does not. Fade-in-on-scroll is a progressive enhancement, so the fallback is simply that the content is visible, which is the correct default anyway.
When this applies
Fading or moving elements in as they scroll into view, or driving a reading-progress bar.
The native approach
@keyframes fade-in {
from {
opacity: 0;
translate: 0 2rem;
}
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.reveal {
animation: fade-in linear both;
animation-timeline: view();
/* Run from entering the viewport to 40% up it. */
animation-range: entry 0% cover 40%;
}
}
}
/* A reading-progress bar, no JavaScript. */
@keyframes grow {
from {
scale: 0 1;
}
}
.progress {
animation: grow linear both;
animation-timeline: scroll(root block);
transform-origin: left;
}When the dependency is still right
An answer that always says "the platform covers it" is worse than no answer. These are the cases where this one does not hold.
- You need Firefox support. Chrome 115 and Safari 26 ship scroll-driven animations but Firefox does not, so wrap them in @supports and make sure the un-animated state is the visible one.
- You need a callback in JavaScript when an element enters view, for analytics, lazy loading or infinite scroll. That is what IntersectionObserver is for and CSS does not replace it.
- You need to animate something that is not a descendant of, or a sibling reachable from, the scrolling element. Timeline scope is limited.
- The animation must run once and stay put. A view timeline is scrubbed, so it reverses when the user scrolls back up.
- You are using the library's IntersectionObserver for anything other than animation, in which case removing it changes unrelated behaviour.
Building it
This catalog stops at the swap. These guides go through the implementation and the fallbacks, and each one reads in the terminal through its own command.
- Parallax scroll effects
npx -y modern-web-guidance@latest retrieve "parallax-scroll-effects" - Scrollytelling
npx -y modern-web-guidance@latest retrieve "scrollytelling" - Scroll entry exit effects
npx -y modern-web-guidance@latest retrieve "scroll-entry-exit-effects" - Scroll progress indicator
npx -y modern-web-guidance@latest retrieve "scroll-progress-indicator" - Shrinking header on scroll
npx -y modern-web-guidance@latest retrieve "shrinking-header-on-scroll"
Signs it was hand-rolled
No package is involved in any of these, so nothing would match in a package.json. If the code looks like one of them, this rule applies anyway, and the conditions above still decide.
- a scroll listener setting a progress bar's width from scrollY over scrollHeight
- mapping scroll position to a transform or an opacity on every scroll event