Typically designers have foolish concepts that ultimately develop on you. That occurred to me with this idea the place I needed to construct columns of things transferring in reverse instructions when a consumer scrolls the web page.
Observe: This demo respects decreased movement settings, so that you’ll must allow movement to see the impact. And we’re Chrome and Safari assist as I’m scripting this.
It’s actually not as exhausting as you may suppose, due to fashionable CSS options, particularly scroll-driven animations. Not solely that, however it’s enjoyable to make, too! Let me present you the way I approached it — and possibly it would be best to share how you’ll do it in another way.
The HTML
The HTML consists of a dad or mum ingredient (.opposing-columns), its youngsters (.opposing-column), and its youngsters’s youngsters (.opposing-item):
That is all we'd like within the markup. CSS will do the remaining!
Styling the dad or mum container
First off, we’re going to set issues up in order that this impact solely applies to bigger screens — there’s no actual sense in supporting one thing like this on smaller screens as a result of we'd like the extra area for the impact.
/* Simply on bigger screens */
@media display and (width >= 50rem) {
.opposing-columns {
show: flex;
hole: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
}
}
Organising a “masking” impact
We have to do a number of extra issues with the dad or mum container to get the phantasm that gadgets in every .opposing-column are disappearing as they scroll previous it. The gadgets within the outer columns transfer upward on scroll, and gadgets within the middle column transfer downward. As they cross the mother and father’ boundaries, we wish them to sorta fade out.
So, we’re going to do a number of issues. First, we’ll set a background shade variable on the doc as an entire:
@media display and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* similar kinds as earlier than */
}
}
Second, we’ll apply that very same background shade on the dad or mum’s :earlier than and :after pseudo-elements:
@media display and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* similar kinds as earlier than */
&:earlier than,
&:after {
content material: "";
place: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}
Discover that we’ve established a stacking context on the pseudos and set them one layer above the dad or mum and its descendants. That is key for masking the gadgets in every column as they scroll out and in of the container. The gadgets are technically sliding below the pseudo masks.
Talking of which, let’s create one other variable referred to as --opposing-mask that provides vertical area between the dad or mum ingredient and the three columns:
@media display and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
show: flex;
hole: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
margin-block: var(--opposing-mask, 3rem);
place: relative;
}
}
Let’s do the identical factor to the mother and father’ pseudos, solely making use of --opposing-mask to their block-size by a a number of of three. This fashion, there’s extra vertical area between them and the dad or mum.
@media display and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* similar kinds as earlier than */
&:earlier than,
&:after {
content material: "";
place: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}

You may see the place that is going. Now we have a pleasant quantity of area between the dad or mum container and its pseudos. We wish the column gadgets to seem as if they're fading out as they scroll out of the dad or mum container. We don’t must mess with their opacity or something like that. As a substitute, we are able to add background gradients on the pseudos.
The :earlier than pseudo is on the prime of the container, so we’ll give it a gradient that goes from a strong shade that matches the doc’s underlying background shade to clear, top-to-bottom. And because the :after pseudo sits on the backside of the dad or mum container, we’ll reverse the gradient so it goes clear to the doc’s background shade, bottom-to-top.
@media display and (width >= 50rem) {
:root {
/* similar kinds as earlier than */
}
.opposing-columns {
/* similar kinds as earlier than */
&:earlier than,
&:after {
/* similar kinds as earlier than */
}
&:earlier than {
background-image: linear-gradient(
to backside,
var(--opposing-bg) var(--opposing-mask),
clear
);
inset-block-start: calc(var(--opposing-mask) * -1);
}
&:after {
background-image: linear-gradient(
to prime,
var(--opposing-bg) var(--opposing-mask),
clear
);
inset-block-end: calc(var(--opposing-mask) * -1);
}
}
}
}
The column layouts
Earlier than we get to the magic, we ought to put out the gadgets in every column. Every column is a flex merchandise contained in the dad or mum, which is a flex container. We’ll allow them to shrink (flex-shrink: 1) and develop (flex-grow: 1), capping the scale at a sure level (flex-basis: 10rem).
We are able to outline all that with the flex shorthand property:
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
flex: 1 1 10rem;
}
}
Now I need these columns to be grid containers so I can use the hole property to insert area between gadgets:
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
flex: 1 1 10rem;
show: grid;
hole: 2rem;
}
}
We completely may have used Flexbox right here as properly to get entry to hole, however the default structure is ready to row and we’d must override that to column. Grid is a bit more concise on this scenario.
The animation!
That is what you got here for, proper? We’ve set all the pieces up in order that column gadgets can circulation out and in of the dad or mum container on scroll. Now we have to add that scrolling habits.
That is the place the animation-timeline property comes actual helpful. Usually, a CSS animation simply runs by itself. It begins when the web page hundreds (or after a particular delay you set) and ends after nonetheless lengthy you set the period. With animation-timeline, we inform the animation to run based mostly on its scroll place… therefore the time period “scroll-driven” animation.
Now we have two supported features right here, scroll() and view(). They’re associated however tremendous completely different in that scroll() runs the animation based mostly on a component’s scroll place. The view() operate is analogous, however tracks the ingredient’s progress because it enters and exits the scrollport (i.e., the scrollable space of the container it's in).
We’re going with the view() operate as a result of we’ve set this up the place there's a clear scrollable space contained in the dad or mum container. We have to run the animation based mostly on the place it enters and exits that space quite than the scroll place of the column gadgets.
That is actual attention-grabbing as a result of we are able to inform view() the place precisely we wish the animation to begin as soon as it enters the scrollable space and the place to cease as soon as it exits that very same space. Like this:
/* Official syntax */
animation-timeline: view([ || <'view-timeline-inset'>]?);
Let’s begin by defining the axes:
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry cowl;
}
}
That is simply partially what we wish, however what we’re saying is we wish the animation to (1) begin the very second is enters the scrollport (entry), and (2) finish when it utterly leaves the realm (cowl). We should be explicitly in regards to the insets as a result of that’s what establishes the animation’s vary relative to the place it enters and exits. We wish the complete vary, so the entry begins at 0% and the exit is when an merchandise is cowled at 100%.
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry 0% cowl 100%;
}
}
Lastly, we’ll set the animation to run linearly — no want for the gadgets to sluggish up or down as they scroll.
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
/* ... */
animation-timing-function: linear;
animation-timeline: view();
animation-range: entry 0% cowl 100%;
}
}
OK, nice. However what we haven’t carried out is create an animation. We’ve arrange what we wish it to do when it runs, however we have to outline the precise motion.
I need to arrange three separate CSS animations:
- One which interprets (strikes) the gadgets upward within the first column.
- One which’s the reverse of the primary animation for the gadgets within the different column.
We may technically set the primary animation on each of the outer columns, however I need a third one that may be a little bit offset from the primary so these columns seem staggered.
@keyframes scroll1 {
from { rework: translateY(var(--opposing-mask)); }
to { rework: translateY(calc(var(--opposing-mask) * -1)); }
}
@keyframes scroll2 {
from { rework: translateY(calc(var(--opposing-mask) * -1)); }
to { rework: translateY(var(--opposing-mask)); }
}
@keyframes scroll3 {
from { rework: translateY(calc(var(--opposing-mask) * .66)); }
to { rework: translateY(calc(var(--opposing-mask) * -.33)); }
}
We are able to create variables for these, after all, ought to we ever must replace them:
@media display and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
--animation-1: scroll1;
--animation-2: scroll2;
--animation-3: scroll3;
/* ... */
}
}
…and apply them to every column:
@media display and (width >= 50rem) {
/* similar kinds as earlier than */
.opposing-column {
/* similar kinds as earlier than */
}
:the place(.opposing-column:nth-of-type(1)) {
animation-name: var(--animation-1);
}
:the place(.opposing-column:nth-of-type(2)) {
animation-name: var(--animation-2);
}
:the place(.opposing-column:nth-of-type(3)) {
animation-name: var(--animation-3);
}
}
Whereas we’re at it, we must always disable the animations to respect the consumer’s settings for decreased movement (and take away the masks, in any other case it would look bizarre):
@media (prefers-reduced-motion: scale back) {
.opposing-column {
animation: unset;
&:earlier than,
&:after {
content material: unset;
}
}
}
Wrapping up
So yeah, scroll-driven animations are actually, actually cool. We’re nonetheless ready for Firefox assist as I’m scripting this, however you'll be able to actually wrap this in @helps to supply a default expertise that makes use of thew scroll annotations after which set a fallback expertise for non-supporting browsers, like working on a traditional animation timeline:
@helps (animation-timeline: view()) {
/* ... */
}
That is simply toe-dipping into what scroll-driven animations can do, after all. What kind of issues have you ever made or experimented with? Or would you strategy this one in another way? Let me know!
