CSS Scroll-Driven Animations: Replacing My Scroll Script
Two years ago I wrote a file called reveal.js. Forty-odd lines: an IntersectionObserver for the crossings, a requestAnimationFrame loop for everything between them, a cache of getBoundingClientRect() values, and a resize listener whose only job was to throw that cache away. It faded cards in on a landing page. It worked, mostly. On a slow phone the fade trailed the thumb, which is the jank it was written to avoid.
I deleted it last week. The page now ships a dozen lines of CSS and no listeners.
Pick a timeline, not a duration
CSS scroll-driven animations keep your @keyframes as they are and change what moves the playhead. Scroll position replaces the clock. Two functions cover almost everything you'd ship.
scroll() follows how far a scroll container has been scrolled, which is what a reading progress bar needs. view() follows one element's journey through its scrollport, which is what a card reveal needs.
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
background: #2563eb;
transform-origin: left center;
animation: grow-progress linear;
animation-timeline: scroll(root block);
}
There's no duration in that rule. That isn't a typo, and it's the first thing that breaks people. Chrome's guide is blunt: once a scroll timeline is attached, a value in seconds means nothing, so animation-duration has to be auto, or omitted so it defaults to auto. Type it out of muscle memory and the animation quietly stops behaving.
The shorthand will eat your timeline
Second gotcha, and this one cost me an afternoon. animation-timeline gets included in the animation shorthand as a reset-only value. You can't set it there, but writing the shorthand does reset it to auto. So order matters:
/* animation snaps to the end frame immediately */
.card {
animation-timeline: view();
animation: reveal-card linear both;
}
/* works */
.card {
animation: reveal-card linear both;
animation-timeline: view();
}
The failure is silent. No console warning, nothing invalid to spot in DevTools. The animation falls back to the document timeline, and with the duration you never set, it resolves to zero and jumps straight to the last keyframe. It looks like the effect already ran. If a scroll-driven animation does nothing, check this before you check anything else.
Blank cards in Firefox
My first version had .card { opacity: 0; } in the base rule, with the animation bringing it up to 1. Every screenshot looked correct, because Chromium has shipped this since 115 in July 2023 and Safari caught up in 26 in September 2025. Then I opened the page in Firefox and got a column of nothing.
Firefox still ships scroll-driven animations behind a preference (layout.css.scroll-driven-animations.enabled, off by default in the current stable), so @supports reports false there and no user agent sniffing is needed. Which also meant my hidden base state was all that was left on screen.
The rule I use now: write the base CSS as the finished, visible state, and put all the motion inside two guards.
@keyframes reveal-card {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: none; }
}
.card { /* nothing animated here, it's simply visible */ }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal-card linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
}
Fill mode both isn't optional. Without it the from keyframe never applies before the range starts, so the card flashes at full opacity and then fades in, which looks like a bug because it is one.
animation-range is doing more work than it looks. A view timeline defaults to the element's entire cover range, so a reveal plays forward as the card climbs the screen and backwards as it leaves. To have it finished by the time the card is a third of the way up, that range value is the fix. You can also put ranges inside keyframes, which I use for list items that fade back out at the bottom:
@keyframes in-and-out {
entry 0% { opacity: 0; transform: translateY(30%); }
entry 100% { opacity: 1; transform: none; }
exit 0% { opacity: 1; transform: none; }
exit 100% { opacity: 0; transform: translateY(-30%); }
}
Firefox hasn't implemented animation-range or timeline-scope either, so a range-dependent effect is a Chromium and Safari effect today. Global support sits around 87%.
Named timelines, and the wrong scroller
Anonymous timelines are enough until the element you're animating isn't a descendant of the thing that scrolls. Named timelines handle that, and lookup only walks up the scroll ancestors, which is why timeline-scope exists: it hoists the name to a shared ancestor so a sibling can reach it. I've needed it once, on a sticky sidebar tracking the article body beside it.
The related trap is quieter. Bare scroll() means scroll(nearest block), and "nearest" is whatever ancestor happens to be a scroll container. A wrapper with overflow: hidden counts. So does a horizontal carousel you forgot about. Your progress bar then fills over the wrong scroll range, and the CSS looks perfectly reasonable while being wrong. I write scroll(root block) even when I'm sure there's nothing in between.
Keep IntersectionObserver for the rest
These animations scrub a property from a scroll position. They don't fire callbacks, so lazy-loading an image, fetching page two, or pinning a section is still observer work. And stick to transform, opacity, and filter. Animate width or height and the browser relayouts every frame, which throws away the reason the timeline lives on the compositor.
Debugging is better than I expected: the Animations panel lists the animation with a scroll timeline where the clock usually is, and the playhead scrubs like any other animation. If it doesn't show up at all, the timeline failed to resolve.
reveal.js is gone, along with the cache and the resize listener. I kept two things in Snippet Ark: the @supports block above and the ranges I actually use, because I re-derive them from scratch every single time.
This is the same story as the transition library I deleted last year and the click handlers HTML took over. The platform keeps absorbing jobs we used to rent from npm. I'm not complaining.