Перший погляд на scroll-triggered анімації
Chrome випустив scroll-triggered анімації й став першим браузером із такою підтримкою. Якщо оновити до Chrome 146, можна переглянути демонстрацію нижче, де фон квадрата зявляється протягом 300ms`, але лише тоді, коли весь елемент опиняється у видимій області.
У цій статті автор порівняє їх із scroll-driven анімаціями, а також покаже, як працюють scroll-triggered анімації.
Scroll-triggered animations vs. scroll-driven animations
Scroll-triggered анімації відтворюються протягом фіксованої тривалості після того, як було перевищено певний поріг прокрутки. (Думайте про це як про Intersection Observer API у JavaScript, але для CSS-анімацій.)
Це відрізняється від scroll-driven анімацій, де прогрес анімації синхронізується з прогресом прокрутки (animation-timeline: scroll()) або ступенем перетину (animation-timeline: view()), і тому такі анімації не мають фіксованої тривалості.
Basic scroll-triggered animation example
Ключова частина — timeline-trigger: view() замість animation-timeline: view(), що чекає, поки елемент буде всередині порогу, замість вимірювання того, наскільки він у ньому і виконання дій відповідно. Проте почнемо з власне @keyframes анімації, яка встановлює background:
/* Define the animation */
@keyframes fade-bg-in {
to {
background: currentColor;
}
}
Вона застосовується до .square з тривалістю 300ms:
.square {
/* Declare animation */
animation: fade-bg-in 300ms;
}
За замовчуванням CSS-анімації запускаються, коли оголошення застосовується, але в розширеному прикладі нижче timeline-trigger перезаписує таку поведінку. Тепер анімація запускається, коли елемент потрапляє у view(). --trigger — це просто dashed ident, який виступає ідентифікатором тригера, тоді як entry 100% exit 0% — це timeline range. Timeline range визначає зону прокрутки, у межах якої анімація активується і може залишатися активною.
У цьому випадку анімація тригериться, коли нижній край .square входить (entry 100%), і припиняється (якщо вона досі виконується), коли верхній край виходить із viewport (exit 0%). Для ясності: entry 0% тригерив би анімацію, коли верхній край входить. entry обробляє появу елемента знизу в області прокрутки, тоді як exit обробляє його вихід через верх. Це трохи заплутано, але легше зрозуміти без надмірних пояснень.
.square {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation trigger conditions */
timeline-trigger: --trigger view() entry 100% exit 0%;
}
Для animation-trigger спочатку вказуємо, про який тригер іде мова, а потім оголошуємо деякі налаштування (наприклад, play-forwards):
.square {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation trigger conditions */
timeline-trigger: --trigger view() entry 100% exit 0%;
/* Animation trigger settings */
animation-trigger: --trigger play-forwards;
}
Ключове слово play-forwards запускає анімацію щоразу, коли квадрат стає повністю видимим, і оскільки для анімації не вказано режим заповнення (animation-fill-mode або як частина шортену animation), квадрат не зберігатиме фон після завершення — анімація виглядає як спалах.
Тож потрібно далі розширювати приклад, щоб досягти різних результатів.
animation-fill-mode vs <animation-action>
Спочатку коротко про те, як працюють різні значення animation-fill-mode або коли вказані в шортен-формі animation:
forwards: стилі зберігаються після завершення анімації.backwards: стилі застосовуються перед анімацією.both: застосовуються обидві поведінки.
Припустимо, що <animation-action> встановлене в play-forwards (як раніше), а fill mode — forwards (both був би зайвим, бо background спочатку не встановлений):
.square {
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
animation-trigger: --trigger play-forwards;
}
Це змушує стилі зберігатися, але якщо квадрат частково або повністю вийде з viewport, а потім знову увійде, анімація перезапуститься, що може спричинити спалах залежно від кінця анімації, як у цьому прикладі.
Є два різні шляхи вирішення цієї проблеми…
Метод «закріплення»: використати play-once замість play-forwards, що в комбінації з forwards призводить до того, що анімація відтворюється один раз, більше не перезапускається, і після цього стилі зберігаються.
.square {
/* Play once */
animation-trigger: --trigger play-once;
/* Retain the styles */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
Метод «туди й назад»: play-forwards play-backwards анімує елемент нормально, коли він повністю видимий, і у зворотному напрямку, коли він більше не повністю видимий. Немає спалаху, бо елемент анімується назад так само плавно, як і вперед. Крім того, хоча напрямок анімації може змінюватися, режим заповнення може залишатися forwards, а не both.
Чому?
play-forwards означає «грати анімацію від 0% до 100%», тоді як play-backwards означає «грати анімацію від 100% до 0%». У свою чергу, forwards fill mode означає «зберегти стилі, коли анімація завершується» — це справедливо незалежно від того, чи кінцевий ключовий кадр 0% чи 100%.
.square {
/* Play forward and backward, as appropriate */
animation-trigger: --trigger play-forwards play-backwards;
/* Retain the styles either way */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
play-forwards, play-once і play-backwards не є єдиними ключовими словами для <animation-action>. Короткий огляд:
<animation-action> |
Ефект |
|---|---|
none |
Для умовного вимкнення тригерів, на entry але не на exit (або навпаки), або для роботи з кількома тригерами одним animation-trigger |
play-forwards |
Дозволяє анімації відтворюватися вперед |
play-backwards |
Дозволяє анімації відтворюватися назад |
play-once |
Вперед або назад (те, що трапиться першим) |
play |
Відтворює в останньо вказаному напрямку, або вперед, якщо жодного не вказано |
pause |
Паузує анімацію |
reset |
Ставіть паузу й встановлює прогрес у 0 |
replay |
Встановлює прогрес у 0, але не ставить на паузу |
Ці <animation-action> дають багато контролю над анімаціями під час прокрутки: різні комбінації дій, fill mode, timeline ranges, а також можливість вкладати exit-анімації в правила @keyframes означають, що часто існує кілька способів досягти потрібного результату.
Scroll-triggering multiple elements
Хоча те, що scroll-triggered анімації складаються з animation actions, fill modes, timeline ranges і, можливо, інших атрибутів, може здаватися надмірно складним, те, що ці механіки розділені, дозволяє повторно використовувати логіку, зберігаючи гнучкість, зменшуючи дублювання й роблячи механіку дружнішою до дизайн-систем.
Розглянемо цього разу три квадрати, і для додаткової складності встановимо scale: 70% (анімується до initial) і визначимо дві обертальні анімації.
<div id="squares">
<div class="square rotate-left"></div>
<div class="square"></div>
<div class="square rotate-right"></div>
</div>
/* Define animations */
@keyframes intensify {
to {
scale: initial;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.square {
/* Set starting value */
scale: 70%;
}
Далі — більше того самого, і хоча це очевидно складніший приклад, можливість об'єднувати значення в шорткези і розкладати їх у лонгхенди, а також розділена природа різних механік сприяє гнучкості й повторному використанню (у цьому випадку — щоб створити лаги для різних анімацій, використовуючи ті самі налаштування тригера):
.square {
/* Set starting value */
scale: 70%;
/* Define animation name */
--base-animation: intensify;
/* Declare animation */
animation: var(--base-animation) 300ms forwards;
/* Define animation trigger settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one of either rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
/* Declare animation trigger conditions (without timeline ranges) */
timeline-trigger: --trigger view();
/* Declare active range end */
timeline-trigger-active-range-end: normal;
/* Append other animations */
&.rotate-left {
animation-name: var(--base-animation), rotate-left;
}
&.rotate-right {
animation-name: var(--base-animation), rotate-right;
}
/* Stagger activation ranges */
&:first-child {
timeline-trigger-activation-range-start: entry 33.3333%;
}
&:nth-child(2) {
timeline-trigger-activation-range-start: entry 66.6666%;
}
&:last-child {
timeline-trigger-activation-range-start: entry 99.9999%;
}
}
Ось чистіша, більш надійна версія, яка використовує sibling-count() і sibling-index() (які наразі відсутні у Firefox) для створення лагів анімацій:
У цій версії замість встановлення timeline-trigger-activation-range-start для кожного окремого квадрата ми просто таргетуємо .square і обчислюємо значення entry на льоту:
/* Maximum entry ÷ number of squares */
--stagger-interval: calc(100% / sibling-count());
/* Current square’s index × stagger interval */
--entry: calc(sibling-index() * var(--stagger-interval));
/* Declare animation trigger conditions */
timeline-trigger: --trigger view() entry var(--entry) exit 0%;
Making one element trigger other elements
У цьому випадку ми перемістимо тригер і його діапазони до першого квадрата, і нехай інші квадрати слідують через відстрочку анімації. Як видно, усі анімації запускаються animation-trigger після того, як 50% першого квадрата увійшло (entry 50%) у viewport (view()). animation-trigger активується timeline-trigger, оскільки dashed ident (названий --trigger) зв'язує їх.
/* Define animations */
@keyframes intensify {
to {
scale: initial;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.square {
/* Set starting value */
scale: 70%;
/* Define animation name */
--base-animation: intensify;
/* Maximum delay ÷ number of squares */
--stagger-interval: calc(300ms / sibling-count());
/* Current square’s index × stagger interval */
--animation-delay: calc(sibling-index() * var(--stagger-interval));
/* Declare animation */
animation: var(--base-animation) 300ms var(--animation-delay) forwards;
/* Define animation trigger settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one of either rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
&:first-child {
/* Declare animation trigger conditions */
timeline-trigger: --trigger view() entry 50%;
/* Declare active range end */
timeline-trigger-active-range-end: normal;
}
/* Append other animations */
&.rotate-left {
animation-name: var(--base-animation), rotate-left;
}
&.rotate-right {
animation-name: var(--base-animation), rotate-right;
}
}
Один недолік: коли animation-trigger у режимі play-backwards, анімації не стають зі зсувача (не stagger). За словами автора, це може бути тому, що коли анімація відтворюється у зворотному напрямку, затримка включається в цей процес. Це виглядає як недогляд, особливо тому, що такої поведінки немає з animation-direction: reverse, але автор може помилятися.
Understanding timeline ranges
Timeline ranges — велика частина scroll-triggered анімацій, але це окрема механіка. Для scroll-driven анімацій вам знадобиться animation-range і його лонгхенд-властивості. У scroll-triggered анімаціях синтаксис по суті той самий, але використовуються інші властивості й два різні діапазони. Activation range визначає зону прокрутки, у якій анімація тригериться, тоді як active range визначає зону, у якій вона утримується (навіть якщо вже не в activation range).
Timeline ranges досить важкі для розуміння. Втім, view() entry 100% exit 0% (коли повністю видимий) і view() contain (те ж саме, але також коли елемент більший за viewport) покривають більшість випадків.
Якщо ж ви хочете поринути глибше, animation-range, хоча й призначений для scroll-driven анімацій, є трохи простішим і дає початкове розуміння timeline ranges. Після цього автор радить читати специфікацію Animation Triggers, щоб охопити численні тонкощі timeline ranges у контексті цих scroll-triggered анімацій.
Іншим складовим елементом scroll-triggered анімацій, що є окремою річчю, є функція view(). Тут коротко: у контексті scroll-triggered анімацій view() — це viewport. Тож якщо у вас є 5rem sticky header, view(y 0 5rem) врахує це по осі y.
Final thoughts
Scroll-triggered анімації можуть бути складними, бо вони схожі на scroll-driven анімації, використовують старіші CSS-фічі (переважно animation), а також механіки з новіших фіч (dashed idents, view(), timeline ranges), поряд із властивостями, специфічними для scroll-triggered анімацій. Тут відбувається одразу дуже багато.
Автор не впевнений у власному ставленні до них. Вони однозначно круті, корисні й цікаві, але також складні, і пройде деякий час, перш ніж автор почне ними захоплюватися повністю.
Джерело: CSS-Tricks