Sometimes designers have silly ideas that eventually grow on you. That happened to me with this concept where I had to build columns of items moving in opposite directions when a user scrolls the page.
Note: This demo respects reduced motion settings, so you’ll need to enable motion to see the effect. And we’re looking at Chrome and Safari support as I’m writing this.
It’s really not as hard as you might think, thanks to modern CSS features, specifically scroll-driven animations. Mot only that, but it’s fun to make, too! Let me show you how I approached it — and maybe you will want to share how you would do it differently.
The HTML
The HTML consists of a parent element (.opposing-columns), its children (.opposing-column), and its children’s children (.opposing-item):
<div class="opposing-columns">
<!-- Column 1 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
<!-- Column 2 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
<!-- Column 3 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
</div>This is all we need in the markup. CSS will do the rest!
Styling the parent container
First off, we’re going to set things up so that this effect only applies to larger screens — there’s no real sense supporting something like this on smaller screens because we need the additional space for the effect.
/* Just on larger screens */
@media screen and (width >= 50rem)
.opposing-columns
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
The column layouts
Before we get to the magic, we ought to lay out the items in each column. Each column is a flex item inside the parent, which is a flex container. We’ll let them shrink (flex-shrink: 1) and grow (flex-grow: 1), capping the size at a certain point (flex-basis: 10rem).
We can define all that with the flex shorthand property:
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
flex: 1 1 10rem;
Now I want those columns to be grid containers so I can use the gap property to insert space between items:
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
flex: 1 1 10rem;
display: grid;
gap: 2rem;
We totally could have used Flexbox here as well to get access to gap, but the default layout is set to row and we’d have to override that to column. Grid is a little more concise in this situation.
The animation!
This is what you came for, right? We’ve set everything up so that column items can flow in and out of the parent container on scroll. Now we need to add that scrolling behavior.
This is where the animation-timeline property comes real handy. Normally, a CSS animation just runs on its own. It starts when the page loads (or after a specific delay you set) and ends after however long you set the duration. With animation-timeline, we tell the animation to run based on its scroll position… hence the term “scroll-driven” animation.
We have two supported functions here, scroll() and view(). They’re related but super different in that scroll() runs the animation based on an element’s scroll position. The view() function is similar, but tracks the element’s progress as it enters and exits the scrollport (i.e., the scrollable area of the container it is in).
We’re going with the view() function because we’ve set this up where there is a clear scrollable area inside the parent container. We need to run the animation based on where it enters and exits that area rather than the scroll position of the column items.
This is real interesting because we can tell view() where exactly we want the animation to start once it enters the scrollable area and where to stop once it exits that same area. Like this:
/* Official syntax */
animation-timeline: view([ <axis> || <'view-timeline-inset'>]?);Let’s start by defining the axes:
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
/* ... */
animation-timeline: view();
animation-range: entry cover;
This is just partially what we want, but what we’re saying is we want the animation to (1) start the very moment is enters the scrollport (entry), and (2) end when it completely leaves the area (cover). We need to be explicitly about the insets because that’s what establishes the animation’s range relative to where it enters and exits. We want the full range, so the entry begins at 0% and the exit is when an item is covered at 100%.
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
/* ... */
animation-timeline: view();
animation-range: entry 0% cover 100%;
Lastly, we’ll set the animation to run linearly — no need for the items to slow up or down as they scroll.
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
/* ... */
animation-timing-function: linear;
animation-timeline: view();
animation-range: entry 0% cover 100%;
OK, great. But what we haven’t done is create an animation. We’ve set up what we want it to do when it runs, but we need to define the actual movement.
I want to set up three separate CSS animations:
- One that translates (moves) the items upward in the first column.
- One that’s the reverse of the first animation for the items in the other column.
We could technically set the first animation on both of the outer columns, but I want a third one that is a little bit offset from the first so those columns appear staggered.
@keyframes scroll1
from transform: translateY(var(--opposing-mask));
to transform: translateY(calc(var(--opposing-mask) * -1));
@keyframes scroll2
from transform: translateY(calc(var(--opposing-mask) * -1));
to transform: translateY(var(--opposing-mask));
@keyframes scroll3
from transform: translateY(calc(var(--opposing-mask) * .66));
to transform: translateY(calc(var(--opposing-mask) * -.33));
We can create variables for these, of course, should we ever need to update them:
@media screen and (width >= 50rem)
:root
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
--animation-1: scroll1;
--animation-2: scroll2;
--animation-3: scroll3;
/* ... */
…and apply them to each column:
@media screen and (width >= 50rem)
/* same styles as before */
.opposing-column
/* same styles as before */
:where(.opposing-column:nth-of-type(1))
animation-name: var(--animation-1);
:where(.opposing-column:nth-of-type(2))
animation-name: var(--animation-2);
:where(.opposing-column:nth-of-type(3))
animation-name: var(--animation-3);
While we’re at it, we should disable the animations to respect the user’s settings for reduced motion (and remove the mask, otherwise it might look weird):
@media (prefers-reduced-motion: reduce)
.opposing-column
animation: unset;
&:before,
&:after
content: unset;
Wrapping up
So yeah, scroll-driven animations are really, really cool. We’re still waiting for Firefox support as I’m writing this, but you can certainly wrap this in @supports to provide a default experience that uses thew scroll annotations and then set a fallback experience for non-supporting browsers, like running on a normal animation timeline:
@supports (animation-timeline: view())
/* ... */
This is just toe-dipping into what scroll-driven animations can do, of course. What sort of things have you made or experimented with? Or would you approach this one differently? Let me know!
Using Scroll-Driven Animations for Opposing Scroll Directions originally handwritten and published with love on CSS-Tricks. You should really get the newsletter as well.