Scroll-driven анімації для колонок, що рухаються в протилежних напрямках


Інколи у дизайнерів виникають дивні ідеї, які з часом починають подобатися. Так сталося з автором цієї концепції: треба було створити колонки елементів, що рухаються у протилежні сторони під час прокрутки сторінки.

Примітка: ця демо-версія поважає налаштування reduced motion, тому для перегляду ефекту потрібно дозволити рух. На момент написання розглядається підтримка в Chrome і Safari.

Це зовсім не так складно, як можна подумати, завдяки сучасним можливостям CSS, зокрема scroll-driven animations. Крім того, це весело робити! Нижче описано підхід автора — можливо, ви захочете поділитися своїм варіантом реалізації.

HTML

Розмітка складається з батьківського елемента (.opposing-columns), його дітей (.opposing-column) та дочірніх елементів цих колонок (.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>

Цього достатньо в розмітці. Усе решта робить CSS!

Оформлення батьківського контейнера

По-перше, налаштуємо так, щоб ефект застосовувався тільки на більших екранах — на менших це не має великого сенсу, бо потрібен додатковий простір для ефекту.

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

Налаштування ефекту «маскування»

Потрібно зробити кілька додаткових речей з батьківським контейнером, щоб створити ілюзію, ніби елементи в кожній .opposing-column зникають, коли прокручуються за межі контейнера. Елементи у зовнішніх колонках рухаються вгору під час прокрутки, а елементи в центральній колонці — вниз. Коли вони перетинають межі батьківського контейнера, потрібно, щоб вони ніби зникавали (затемнювалися).

Демонстрація: елементи колонок, що рухаються в протилежних напрямках

Отже, зробимо кілька кроків. Спочатку встановимо змінну для кольору фону на рівні документа:

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

По-друге, застосуємо той самий колір фону до псевдоелементів :before та :after батьківського елемента:

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

Зауважте, що для псевдоелементів встановлено контекст стеку і вони розташовані над батьківським елементом та його нащадками. Це ключово для маскування елементів у колонках під час їх прокручування: технічно елементи ковзають під псевдо-масками.

Далі створимо змінну --opposing-mask, яка додає вертикальний простір між батьківським елементом і трьома колонками:

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

Підсвічено вертикальний простір між батьківським контейнером і дочірніми елементами

Зробимо те саме для псевдоелементів батьківського контейнера, тільки застосуємо --opposing-mask до їх block-size з множником три, щоб між ними і батьківським елементом був додатковий вертикальний простір.

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

Підсвічено вертикальний простір між батьківським контейнером і його псевдо-елементом ::before

Можливо, вже видно, куди це веде. Маємо достатньо простору між батьківським контейнером і його псевдоелементами. Хочеться, щоб елементи колонок виглядали так, ніби вони згасають при виході за межі контейнера. Не потрібно міняти їхню непрозорість — замість цього можна додати градієнти фону на псевдоелементах.

Псевдо-елемент :before знаходиться вгорі контейнера, отже йому дають градієнт від суцільного кольору, що відповідає фону документа, до прозорого зверху вниз. А оскільки :after лежить унизу контейнера, градієнт для нього перевернутий (з прозорого до кольору фону знизу вгору).

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

Макет колонок

Перед тим як перейти до «магії», варто розташувати елементи в кожній колонці. Кожна колонка — це flex-елемент всередині батьківського flex-контейнера. Нехай вони стискуються (flex-shrink: 1) і розширюються (flex-grow: 1), а їхній розмір обмежений (flex-basis: 10rem).

Це можна визначити за допомогою скороченого властивості flex:

@media screen and (width >= 50rem) {
  /* same styles as before */

  .opposing-column {
    flex: 1 1 10rem;
  }
}

Тепер зробимо так, щоб ці колонки були grid-контейнерами, щоб можна було використовувати gap для відступів між елементами:

@media screen and (width >= 50rem) {
  /* same styles as before */

  .opposing-column {
    flex: 1 1 10rem;
    display: grid;
    gap: 2rem;
  }
}

Тут також можна було б використати Flexbox, але за замовчуванням він має напрямок row, який довелося б переоприділяти на column. Grid тут є трохи компактнішим рішенням.

Анімація!

Ось до чого ми йшли. Налаштовано все так, щоб елементи колонок могли входити й виходити з області батьківського контейнера під час прокрутки. Тепер потрібно додати саму поведінку прокрутки.

Тут дуже корисне властивість animation-timeline. Звичайна CSS-анімація працює сама по собі: запускається при завантаженні сторінки (або після заданої затримки) і завершується після заданої тривалості. За допомогою animation-timeline можна вказати, щоб анімація виконувалася залежно від положення прокрутки — звідси і термін «scroll-driven».

Є дві функції, які підтримуються тут: scroll() і view(). Вони пов'язані, але дуже відрізняються: scroll() запускає анімацію залежно від позиції прокрутки елемента. view() схожа, але відстежує прогрес елемента при вході і виході з scrollport (тобто області прокрутки контейнера, в якому він знаходиться).

У статті обрано view(), тому що налаштовано явну прокручувану область всередині батьківського контейнера. Потрібно виконувати анімацію залежно від того, де елемент входить і виходить із цієї області, а не від позиції прокрутки самих елементів колонок.

Цікаво, що view() дозволяє вказати, де саме має починатися анімація під час входу в область і де має закінчуватися під час виходу. Наприклад:

/* Official syntax */
animation-timeline:  view([ <axis> || <'view-timeline-inset'>]?);

Почнемо з визначення осей:

@media screen and (width >= 50rem) {
  /* same styles as before */

  .opposing-column {
    /* ... */
    animation-timeline: view();
    animation-range: entry cover;
  }
}

Це частково те, що потрібно, але тут кажуть: (1) почати щойно елемент входить у scrollport (entry), і (2) закінчити коли він повністю його залишає (cover). Потрібно бути більш явними щодо inset-ів, оскільки саме вони встановлюють діапазон анімації від входу до виходу. Хочеться повного діапазону, отже entry починається на 0%, а exit (cover) відповідає 100%.

@media screen and (width >= 50rem) {
  /* same styles as before */

  .opposing-column {
    /* ... */
    animation-timeline: view();
    animation-range: entry 0% cover 100%;
  }
}

Останнім кроком задаємо лінійну функцію таймінгу — елементам не потрібно уповільнюватися чи прискорюватися під час прокрутки.

@media screen and (width >= 50rem) {
  /* same styles as before */

  .opposing-column {
    /* ... */
    animation-timing-function: linear;
    animation-timeline: view();
    animation-range: entry 0% cover 100%;
  }
}

Добре. Але наразі ми ще не створили саму анімацію. Налаштовано, коли вона має запускатися, але потрібно визначити фактичний рух.

Автор хоче налаштувати три окремі CSS-анімації:

  1. Одна, що транспортує (переміщує) елементи вгору в першій колонці.

  2. Друга — зворотна до першої для елементів в іншій колонці.

  3. Третя — трохи зміщена від першої, щоб зовнішні колонки виглядали складами (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)); }
}

Можна створити змінні для назв цих анімацій, якщо буде потрібно їх змінювати:

@media screen and (width >= 50rem) {
  :root {
    --opposing-bg: lightcyan;
    --opposing-mask: 3rem;
    --animation-1: scroll1;
    --animation-2: scroll2;
    --animation-3: scroll3;

    /* ... */
  }
}

І застосувати їх до кожної колонки:

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

Також варто відключати анімації, щоб поважати налаштування користувача prefers-reduced-motion (і тоді маска може виглядати дивно, тож її також прибирають):

@media (prefers-reduced-motion: reduce) { 
  .opposing-column {
    animation: unset;

    &:before,
    &:after {
      content: unset;
    }
  }
}

Підсумок

Отже, scroll-driven анімації дійсно дуже цікаві. На момент написання Firefox ще не підтримує цю функціональність, але можна обгортати реалізацію в @supports, щоб забезпечити дефолтну поведінку з використанням традиційної анімаційної часової шкали та задати резервний варіант для браузерів без підтримки:

@supports (animation-timeline: view()) {
  /* ... */
}

Це лише невеликий вступ у можливості анімацій, керованих прокруткою. Які речі ви створювали або експериментували з цим підходом? Як би ви реалізували цей приклад по-іншому? Будь ласка, поділіться своїми ідеями.


Джерело: CSS-Tricks


css, анімації, прокрутка, браузери 2026-08-02

Зворотний зв'язок
Вхід