Sometimes a designer’s quirky idea turns into something you actually enjoy building. That’s exactly what happened with this project: columns of content sliding in opposite directions as the user scrolls.

Live demo (CodePen): the effect respects reduced motion settings, so you’ll need to enable motion to see it. As of writing, it works in Chrome and Safari.

Modern CSS makes this surprisingly straightforward — scroll-driven animations are the hero here. Let’s walk through the approach. Maybe you’ll spot a different way to do it.

The HTML Structure

Three nested layers: a parent container (.opposing-columns), child columns (.opposing-column), and items inside each column (.opposing-item).

<div class="opposing-columns">
  <div class="opposing-column">
    <div class="opposing-item">...</div>
    <div class="opposing-item">...</div>
  </div>
  <div class="opposing-column">
    <div class="opposing-item">...</div>
  </div>
  <div class="opposing-column">
    <div class="opposing-item">...</div>
  </div>
</div>

That’s it. CSS handles the rest.

Responsive Setup

The effect only makes sense on larger screens where there’s enough horizontal space. We wrap everything in a media query:

@media screen and (width >= 50rem) {
  .opposing-columns {
    display: flex;
    gap: 2rem;
    max-inline-size: min(90dvi, 50rem);
    margin-inline: auto;
  }
}

Creating the Masking Illusion

Items in the outer columns move upward on scroll; those in the centre column move downward. To make them fade as they cross the container’s boundaries, we use pseudo-elements with gradients — no opacity tricks needed.

First, we set a background colour variable on :root:

:root {
  --opposing-bg: lightcyan;
  --opposing-mask: 3rem;
  background-color: var(--opposing-bg);
}

Then we apply :before and :after on the parent with the same background colour and z-index: 1 to sit above the columns:

.opposing-columns {
  position: relative;
  margin-block: var(--opposing-mask, 3rem);

  &:before,
  &:after {
    content: "";
    position: absolute;
    inset-inline: 0;
    block-size: calc(var(--opposing-mask) * 3);
    pointer-events: none;
    z-index: 1;
  }
}

Now the gradients:

  • Top pseudo (:before): solid background to transparent, top-to-bottom.
  • Bottom pseudo (:after): transparent to solid background, bottom-to-top.
.opposing-columns {
  &: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);
  }
}

Items slide under these masks as they scroll, creating a clean fade at the container’s top and bottom edges.

Practical Considerations for Cyprus & EU Businesses

  • Performance: scroll-driven animations run on the compositor thread — no JavaScript jank. That matters for mobile users on 4G in Limassol or Nicosia.
  • GDPR & Accessibility: the demo respects prefers-reduced-motion. Always include that check to avoid violating EU accessibility directives.
  • Multilingual sites (EN/RU/EL): this CSS technique works regardless of language switching or RTL layouts (use inset-inline and inset-block as we did).
  • Budget estimate: for a custom component like this, expect 2–4 hours of development time. Combined with a full website build (5–10 pages), typical costs from a Cyprus web studio range €1,500–€3,500.