Opposing Scroll Columns With Scroll-Driven Animations
Some design ideas feel odd at first, then click once you see them in motion. That was the case for me with a recent concept: columns of items that travel in opposite directions as the page scrolls. Thanks to scroll-driven animations in CSS, this effect is surprisingly straightforward—and fun to build. Here’s how I approached it.
Note: This demo respects reduced motion settings, so you'll need to enable motion to see the effect. Chrome and Safari support these features as of this writing.
Markup & Layout Foundation
The HTML uses only three levels: a parent .opposing-columns, its children .opposing-column, and the items .opposing-item inside each column:
<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>
The parent is a flex container, with children capped through the flex shorthand:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
flex: 1 1 10rem;
}
}
Each column becomes a grid container to leverage the gap property for spacing between items:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
flex: 1 1 10rem;
display: grid;
gap: 2rem;
}
}
Flexbox could also handle the spacing, but grid’s default row direction is more concise than overriding Flexbox’s column direction.
Masking the Scroll Edges
The illusion of disappearing items relies on pseudo-element masks on the parent container. First, define a background variable at the document level:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* same styles as before */
}
}
Apply that same color to the parent’s :before and :after pseudos, and layer them above the parent’s descendants:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* same styles as before */
&:before,
&:after {
content: "";
position: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}
A --opposing-mask variable adds vertical gap between the parent and its columns:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
margin-block: var(--opposing-mask, 3rem);
position: relative;
}
}

Apply that variable to the pseudos' block size, multiplied by three for additional separation:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* same styles as before */
&:before,
&:after {
content: "";
position: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}

The pseudos themselves carry background gradients. The top (:before) gradient runs from the document’s solid background to transparent; the bottom (:after) reverses that direction. Items sliding under these masks appear to fade at the container’s edges without touching their opacity.
@media screen and (width >= 50rem) {
:root {
/* same styles as before */
}
.opposing-columns {
/* same styles as before */
&:before,
&:after {
/* same styles as before */
}
&:before {
background-image: linear-gradient(
to bottom,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-start: calc(var(--opposing-mask) * -1);
}
&:after {
background-image: linear-gradient(
to top,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-end: calc(var(--opposing-mask) * -1);
}
}
}
}
For small screens, this setup is unnecessary. Guard the effect with a media query so it runs only where space permits:
/* Just on larger screens */
@media screen and (width >= 50rem) {
.opposing-columns {
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
}
}
Setting the Animation Timeline
The animation-timeline property ties animations to scroll behavior. Standard animations play on load; animation-timeline drives them by position. The scroll() function tracks an element’s scroll position, while view() follows when an element enters and leaves the scrollport—the behavior needed here, since items must animate relative to their entry and exit from the scrollable container.
Define axis, ranges, and timing explicitly so the animation starts the moment an item appears and ends only when it’s fully covered:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry cover;
}
}
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}
Run it linearly so movement stays constant:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timing-function: linear;
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}
The full-range configuration looks like this:
/* Official syntax */
animation-timeline: view([ <axis> || <'view-timeline-inset'>]?);
Three Distinct Movements
Three animations handle the motion. One moves the first column upward:
@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)); }
}
One reverses that movement for the column on the other side, and a third creates a staggered offset between the outer columns. Store these as variables for easy updates:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
--animation-1: scroll1;
--animation-2: scroll2;
--animation-3: scroll3;
/* ... */
}
}
Apply the variable-defined animations 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);
}
}
Reduced-motion users get a static layout—both the masks and the animations are disabled—so nothing appears broken or jarring:
@media (prefers-reduced-motion: reduce) {
.opposing-column {
animation: unset;
&:before,
&:after {
content: unset;
}
}
}
Fallback for Unsupported Browsers
Firefox still lacks support at the time of writing, so wrap the feature in @supports. Browsers that pass proceed with scroll-driven behavior; others fall back to a default animation timeline:
@supports (animation-timeline: view()) {
/* ... */
}
Scroll-driven animations keep expanding what CSS alone can do. This columns layout only scratches the surface of view() and range settings—there’s plenty of room to push further.



