Opposing scroll columns with one view() timeline and three keyframe sets
Iris Calderón
You have adjacent columns of cards and you want them drifting in opposite directions as the visitor scrolls — one rising while the other falls. The reflex is to reach for a scroll handler, request a frame, translate each column by hand. CSS-Tricks just walked through the version where you write none of that. One scroll-driven animation primitive does the lift.
The timeline lives on the item, not the page
The mechanism is animation-timeline: view(). Set it on each card and the card's own progress through the scrollport becomes the animation's clock. Scroll past it, the animation completes. Scroll back, it reverses. No scroll listener and no global state. The browser ties the playhead to the geometry it already computes during scrolling.
This is the move worth slowing down on. With view() the timeline is per-element. Two columns side by side each generate their own clocks. From there, opposite directions are just opposite keyframes.
What animation-range: entry 0% cover 100% actually does
The other half is the range. animation-range: entry 0% cover 100% starts the animation the instant the card enters the scrollport and runs it through the cover range. The animation does not fire from a midpoint. It begins at first contact and ends when the card is on its way out. Cards never sit still during their visible life. They are always somewhere along the timeline.
.card {
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
Three keyframe sets, with one offset
The article defines three separate @keyframes blocks, not two. Two carry the basic opposite-direction motion. The third is offset from the first so the outer columns land staggered against the inner ones. Same primitive, same timeline, different keyframes. That is all the variation needs to be.
If you have ever hand-tuned a scroll handler to do this, the size of the win is in what you stop writing. Three keyframe sets and a timeline replace the deltas, the per-direction velocity, the scroll lock.
Linear motion is not a default to ignore
The motion runs linearly. That matters because the scroll position is already an input the user controls. Easing on top of scroll fights the user's hand. Linear lets the animation track the scroll one for one and feel like direct manipulation rather than something the page is doing to itself.
The author also wraps the whole effect in prefers-reduced-motion. If the OS has been told to keep motion calm, the animation does not run.
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
This is not optional craft. Anything driven by scroll position is motion in response to interaction, and prefers-reduced-motion is the platform's way of asking you to stop. Honour it.
Feature detection because Firefox is not there yet
At the time the source was written, scroll-driven animations were still pending in Firefox. The author wraps the effect in feature detection so the layout degrades gracefully where the timeline is not available. The columns sit as ordinary stacks, the page still works, and nobody gets a broken-looking shell. If you ship this, that wrapper is the part you cannot skip.
What this unlocks for your next layout
The interesting thing about animation-timeline: view() is not that you can make columns drift opposite ways. It is that any per-element visual state you used to push from JavaScript can collapse into a @keyframes block and a range. Once you have internalised "the item's own scrollport progress is the clock", the surface area of scroll-tied UI shrinks. You stop reaching for libraries that translate transforms inside a scroll handler.
What is still missing is uniform support, which is why the feature detect matters more than the keyframes. Try the technique on a side project this week, build the no-script fallback as the real layout, and treat the timeline as the enhancement. That is the shape of the new CSS for now.
Source: CSS-Tricks (css-tricks.com)