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 знаходиться вгорі контейнера, отже йому дають градієнт від суцільного кольору, що відповідає фону документа, до прозорого зверху вниз. А оскільки :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-анімації:
-
Одна, що транспортує (переміщує) елементи вгору в першій колонці.
-
Друга — зворотна до першої для елементів в іншій колонці.
-
Третя — трохи зміщена від першої, щоб зовнішні колонки виглядали складами (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