惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
爱范儿
爱范儿
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Jina AI
Jina AI
雷峰网
雷峰网
The Register - Security
The Register - Security
The Cloudflare Blog
博客园 - 【当耐特】
M
MIT News - Artificial intelligence
I
InfoQ
博客园 - 三生石上(FineUI控件)
H
Help Net Security
博客园 - 司徒正美
Vercel News
Vercel News
WordPress大学
WordPress大学
S
SegmentFault 最新的问题
云风的 BLOG
云风的 BLOG
B
Blog
Google DeepMind News
Google DeepMind News
B
Blog RSS Feed
L
LangChain Blog
人人都是产品经理
人人都是产品经理
GbyAI
GbyAI
T
The Blog of Author Tim Ferriss
T
Tailwind CSS Blog
aimingoo的专栏
aimingoo的专栏
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recorded Future
Recorded Future
小众软件
小众软件
Martin Fowler
Martin Fowler
罗磊的独立博客
Stack Overflow Blog
Stack Overflow Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
酷 壳 – CoolShell
酷 壳 – CoolShell
腾讯CDC
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Apple Machine Learning Research
Apple Machine Learning Research
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
博客园 - Franky
Hugging Face - Blog
Hugging Face - Blog
Y
Y Combinator Blog
V
Visual Studio Blog
F
Fortinet All Blogs
Microsoft Azure Blog
Microsoft Azure Blog
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
V
V2EX
Blog — PlanetScale
Blog — PlanetScale

CSS-Tricks

Get Ready For the Powerful CSS border-shape Property! | CSS-Tricks What’s !important #14: Gap Decorations, random(), <select> field sizing, and More | CSS-Tricks The Shifting Line Between CSS States and JavaScript Events | CSS-Tricks translateZ() | CSS-Tricks translateY() | CSS-Tricks translateX() | CSS-Tricks translate() | CSS-Tricks A First Look at Scroll-Triggered Animations | CSS-Tricks The Siren Song of  ariaNotify() | CSS-Tricks Prop For That | CSS-Tricks What’s !important #13: @function, alpha(), CSS Wordle, and More | CSS-Tricks There’s no need to include ‘navigation’ in your navigation labels | CSS-Tricks Why Isn't My 3D View Transition Working? | CSS-Tricks Creating Memorable Web Experiences: A Modern CSS Toolkit | CSS-Tricks
Using Scroll-Driven Animations for Opposing Scroll Directions | CSS-Tricks
Silvestar Bistrović · 2026-06-22 · via CSS-Tricks

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. Not 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 in 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;
  }
}

Setting up a “masking” effect

We need to do a few more things with the parent container to get the illusion that items in each .opposing-column are disappearing as they scroll past it. The items in the outer columns move upward on scroll, and items in the center column move downward. As they cross the parents’ boundaries, we want them to sorta fade out.

So, we’re going to do a few things. First, we’ll set a background color variable on the document as a whole:

@media screen and (width >= 50rem) {
  :root {
    --opposing-bg: lightcyan;
    background-color: var(--opposing-bg);
  }
  
  .opposing-columns {
    /* same styles as before */
  }
}

Second, we’ll apply that same background color on the parent’s :before and :after pseudo-elements:

@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;
    }
  }
}

Notice that we’ve established a stacking context on the pseudos and set them one layer above the parent and its descendants. This is key for masking the items in each column as they scroll in and out of the container. The items are technically sliding under the pseudo masks.

Speaking of which, let’s create another variable called --opposing-mask that adds vertical space between the parent element and the three 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;
  }
}
Highlighting the vertical space between the parent container and its child elements.

Let’s do the same thing to the parents’ pseudos, only applying --opposing-mask to their block-size by a multiple of three. This way, there’s additional vertical space between them and the parent.

@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;
    }
  }
}
Highlighting the vertical space between the parent container and its before pseudo element.

You might see where this is going. We have a nice amount of space between the parent container and its pseudos. We want the column items to appear as if they are fading out as they scroll out of the parent container. We don’t have to mess with their opacity or anything like that. Instead, we can add background gradients on the pseudos.

The :before pseudo is at the top of the container, so we’ll give it a gradient that goes from a solid color that matches the document’s underlying background color to transparent, top-to-bottom. And since the :after pseudo sits at the bottom of the parent container, we’ll reverse the gradient so it goes transparent to the document’s background color, bottom-to-top.

@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);
      }
    }
  }
}

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:

  1. One that translates (moves) the items upward in the first column.
  2. 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!