Перший погляд на 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


css, анімації, scroll, браузери, вебстандарти 2026-08-02

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