What Chrome 115 made optional
Scroll-driven animations landed in Chrome 115. Most people still aren't using them. Here's what that says about us.
The browser couldn’t. So we wrote JavaScript instead. Scroll listeners. rAF loops. IntersectionObserver callbacks. A small industry grew up around a gap the browser eventually decided to close.
Chrome 115, July 2023. animation-timeline: scroll(). animation-timeline: view(). Firefox 115 the same month. Safari caught up in September 2024. The consulting economy adjusts.
Tie an animation to scroll position instead of time. The browser does the math. The JavaScript file stays closed.
Browser support: Chrome 115+, Firefox 115+, Safari 18+ (September 2024). MDN compatibility table. Write graceful degradation anyway — not everyone updates.
Reading progress. No event listeners.
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
#progress-bar {
position: fixed;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--accent, #c4522a);
transform-origin: left;
animation-name: grow-progress;
animation-duration: 1ms; /* required — Firefox ignores scroll timelines without a duration */
animation-timing-function: linear;
animation-timeline: scroll(root);
}
No window.addEventListener('scroll'). No requestAnimationFrame. No arithmetic. The compositor thread runs it. Same reason CSS transforms feel better than their JavaScript equivalents. Because they are.
scroll(root) targets the document scroller. scroll() without arguments targets the nearest scrollable ancestor. Useful inside overflow containers, which is what the demo uses.
animation-timeline: scroll() tracks the nearest scrollable ancestor. As you scroll down, the bar at the top grows. Nothing on the main thread. The compositor advances the animation directly from scroll position — no wakeup cost, no rAF loop.
For a real page-level bar you'd use scroll(root) instead, which targets the document scroller rather than the element's nearest scrollable ancestor (which in this demo is the box itself).
transform-origin: left is load-bearing here. Without it, scaleX() expands from center. That's the kind of thing you only forget once.
Note the explicit animation-duration: 1ms. Firefox ignores scroll-driven animations if no duration is set. Chrome is more forgiving. Write the 1ms anyway.
The surgical version: view()
scroll() tracks the page. view() tracks an element. Specifically: whether it’s inside the viewport and by exactly how much.
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.section {
animation-name: fade-in-up;
animation-duration: 1ms; /* required for Firefox */
animation-timing-function: ease;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
animation-range: entry 0% entry 30%: the animation starts when the element’s leading edge enters the scrollport, ends when 30% of the element has entered. No IntersectionObserver. No class toggling. No JavaScript thread wakeup.
The honest ledger
What they left out of the announcement:
- Safari 18+ supports it (September 2024). Before that, nothing. Check MDN — the table is current and honest.
- Firefox requires
animation-duration: 1ms. Omit it and scroll-driven animations are silently ignored. Chrome forgives. Firefox doesn’t. Write the1ms. - Debugging is primitive. Chrome DevTools has rudimentary scroll timeline support. You are flying without instruments, which is fine until it isn’t.
scroll()defaults to the nearest scrollable ancestor. For the document scroller, you needscroll(root)explicitly. Easy to overlook. You will overlook it.- No clean “run once” primitive. The animation replays every time the element re-enters range. Sometimes that’s the point. Often it isn’t.
Who this replaces — and who it doesn’t
Intersection Observer isn’t dead. For “run once when the element enters viewport” — the standard entrance pattern — IO is still the cleaner tool. You fire a class and walk away. That’s a real advantage.
Scroll-driven animations are for continuous state: progress bars, parallax, sticky-header transitions, scrubbing effects. Animation belongs to a moment: use IO. Belongs to a position: use this.
We spent five years installing libraries to do arithmetic the browser was always capable of. That’s fine. That’s how it works.
Notice when the invoice has expired.
Non-negotiable:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}