Scroll-Driven Animations: animation-timeline Instead of IntersectionObserver
Scroll-Driven Animations: animation-timeline Instead of IntersectionObserver
The classic reveal-on-scroll goes like this: create an IntersectionObserver, watch a list of elements, and when one crosses the threshold, add an .is-visible class that triggers a CSS transition. The progress bar is worse: a scroll listener recalculating scrollTop / (scrollHeight - clientHeight) and writing it into style.width. Both work. Both have the flaw the INP article was about — the logic lives on the main thread, competing with your event handlers and your rendering.
But there's a second problem with the JavaScript version, and it's the more interesting one, because no amount of optimization fixes it.
Why JS parallax always looks a little off
Modern browsers scroll on the compositor. When you flick a page, the scrolling itself doesn't wait for JavaScript — the compositor moves the already-painted content and shows you frames, fast, independent of whatever the main thread is busy with. That's why a page with heavy JS still scrolls smoothly even as everything else stutters.
The scroll event, though, is delivered to your JavaScript after that has already happened. So the sequence is: the browser scrolls and paints, then tells your listener the new position, and only then can your code move the parallax layer to match. Your layer is always reacting to a scroll position the user has already seen. At speed, this reads as a subtle detachment — the background sliding half a beat behind the foreground, the "swimming" effect that gives away a JS implementation instantly.
This isn't a new discovery. It's precisely why position: sticky exists. Sticky headers used to be JavaScript — a scroll listener toggling position: fixed — and they jittered for exactly this reason, so the browser absorbed the pattern into CSS, where the compositor could handle it without a round-trip through the main thread. The same history explains why wheel and touch listeners became passive by default in Chrome back in 2017: non-passive listeners could call preventDefault(), so the browser had to wait for JavaScript before scrolling at all, and pages felt glued to the finger.
Scroll-driven animations are that same absorption, applied to the whole category. It's an ordinary CSS animation, with one substitution: instead of time driving progress from 0% to 100%, the position of a scroll container does. Scroll halfway, and the animation is halfway. Scroll back, and it plays in reverse. Because it's a declarative link between two values rather than a callback, the browser can run it on the compositor — and it's never reacting to a position the user has already seen.
A progress bar in four lines
The first timeline is scroll(), which tracks a scroll container's position — by default the nearest scrolling ancestor. A reading progress bar is a scaleX animation from 0 to 1:
css
.progress {position: fixed;inset: 0 0 auto 0;height: 4px;transform-origin: left;animation: grow linear both;animation-timeline: scroll(root block);}@keyframes grow {from { transform: scaleX(0); }to { transform: scaleX(1); }}
Three deliberate choices here. linear, because any other easing would make the bar drift out of sync with the actual reading position — the bar isn't decoration, it's a readout. No duration, because duration has no meaning on a scroll timeline; progress comes from scroll position. And scroll(root block) rather than a bare scroll(), naming the document as the scroller explicitly — the default is the nearest scrolling ancestor, and relying on that default is the source of the most common "why is my animation stuck" bug, which we'll get to.
Reveal on scroll: the view() timeline
The second timeline, view(), tracks something different: how far a specific element has travelled through the scrollport. Progress is 0% when it starts entering and 100% when it has fully left. This replaces the observer-plus-class pattern outright:
css
.reveal {animation: fade-up linear both;animation-timeline: view();animation-range: entry 0% entry 60%;}@keyframes fade-up {from { opacity: 0; transform: translateY(32px); }to { opacity: 1; transform: translateY(0); }}
animation-range is the property that makes this usable, and its four named ranges are worth memorizing, because each answers a different question:
entry– from the element's first pixel appearing at the edge of the scrollport to the moment it's fully inside. The range for anything that should arrive.exit– from the element starting to leave to it being fully gone. For fading things out as they go.cover– the element's entire journey, first pixel in to last pixel out. This is the default, which is why an animation with noanimation-rangeonly reaches its end state as the element leaves the screen — almost never what you wanted.contain– only the stretch where the element is fully visible inside the scrollport. Useful for effects that should run while the user is actually looking at the thing.
So entry 0% entry 60% means: play the whole animation over the first 60% of the entry phase, and hold the end state after that. If you want the reveal to start a little before the element reaches the edge, view-timeline-inset lets you shrink or grow the scrollport that the range is measured against — a negative inset starts the animation early, which is usually what "it should already be visible by the time I look at it" means in practice.
The gotcha that will cost you an hour: the animation shorthand resets the timeline
This one is genuinely surprising, it fails silently, and it catches almost everyone once.
animation is a shorthand, and like every CSS shorthand, it resets all of its longhand components to their initial values — including the ones you didn't type. animation-timeline and animation-range are part of that family. So this looks completely reasonable and does nothing at all:
css
/* BROKEN: the shorthand resets animation-timeline back to auto */.reveal {animation-timeline: view();animation-range: entry 0% entry 60%;animation: fade-up linear both;}
The animation runs — on the default time-based timeline, instantly, once, on page load. The element flashes into place the moment the stylesheet applies and scroll has nothing to do with it. The fix is nothing more than ordering:
css
/* Correct: timeline and range come after the shorthand */.reveal {animation: fade-up linear both;animation-timeline: view();animation-range: entry 0% entry 60%;}
Same properties, same values, different order, completely different behavior. It's worth writing it as a rule for yourself: on a scroll-driven animation, animation-timeline and animation-range are always the last two declarations in the block. This bites hardest when the shorthand comes from somewhere else — a utility class, a design system, a @media override — and silently disarms the timeline you set three rules earlier.
Named timelines: animating one thing based on another
scroll() and view() are anonymous — they always refer to the element itself or its scroll ancestor. When you want an element animated by another element's scroll, you name the timeline and reference it elsewhere:
css
.gallery {overflow-x: auto;scroll-timeline: --gallery inline;}.gallery-indicator {animation: fill linear both;animation-timeline: --gallery;timeline-scope: --gallery;}
The --gallery timeline lives on the horizontally scrolling gallery, but an indicator elsewhere in the DOM can follow it. A named timeline is only visible to descendants by default; timeline-scope is what raises the name high enough in the tree for siblings and distant relatives to see it. The same applies to view-timeline-name.
Progressive enhancement is not optional here
Support landed in Chromium first, Safari followed, and Firefox has been slowest — check Can I Use before deciding how much weight to put on it. That makes the correct structure non-negotiable: content fully visible and functional by default, animation layered on top behind @supports.
css
.reveal {opacity: 1; /* default: visible everywhere */}@supports (animation-timeline: view()) {@media (prefers-reduced-motion: no-preference) {.reveal {animation: fade-up linear both;animation-timeline: view();animation-range: entry 0% entry 60%;}}}
This nesting solves two problems at once. Browsers without support get finished content — no flash of hidden text, no empty page. And users who asked for reduced motion get none of it, even in browsers that could run it. Note that the from { opacity: 0 } keyframe still exists; it just never applies unless both conditions hold. That's the difference between an enhancement and a dependency.
Pitfalls and best practices
- Animate
transformandopacity, nothing else. Only those stay on the compositor. Animatingwidth,toporbox-shadowon a scroll timeline reintroduces layout and paint on every scroll frame — arguably worse than the old JavaScript, because now it happens on every pixel of scrolling rather than on a throttled callback. - Never hide content that only an animation can reveal. If
opacity: 0is the base state and the animation is the only thing that raises it, then in an unsupported browser the text is invisible forever, and it's invisible in a way that still occupies layout and still gets read by a screen reader — the worst of both worlds. The base state must be the visible one. - "Why is my animation stuck?" is almost always the scroll container.
scroll()attaches to the nearest ancestor whoseoverflowisn'tvisible. Putoverflow: hiddenon a wrapper for unrelated reasons and the timeline silently binds to that wrapper, which never scrolls, so progress stays at 0% forever. Useoverflow: clipwhere you only want to prevent painting outside the box — it doesn't create a scroll container — or name the scroller explicitly withscroll(root block). - Use
bothfor fill mode. Without it, the element renders its base style until the range begins, then snaps to thefromkeyframe at the boundary. The jump is small, visible, and maddening to diagnose. - Reduced motion is a medical setting, not a courtesy. Parallax and long scroll-linked travel are textbook triggers for vestibular disorders. Gate movement behind
prefers-reduced-motion: no-preference; a short opacity fade is usually fine, a 200px translate is not. - Don't delete JavaScript you still need. If the reveal also fires an analytics event, lazy-loads data, or marks an item as read, that's still
IntersectionObserver's job. The CSS timeline replaces the presentation, not the behavior — and the two were only ever tangled together because they happened to share a trigger.
Conclusion
The INP article argued that the main thread is the scarcest resource on a page, and that the best long task is the one that never runs. Scroll-driven animations are that idea applied to a whole category of effects: what used to need a scroll listener, an observer, a class toggle and a library like ScrollTrigger or AOS becomes one CSS property, executed where it's cheap and where it can't lag behind the scroll the user already saw. Together with View Transitions from the previous article, a pattern emerges — describe the motion declaratively, and let the browser own the drawing. What stays yours is the part that can't be delegated: deciding what deserves to move, and making sure that for everyone who can't or won't see the motion, the page is already complete without it.