Анимация при скролле: Intersection Observer + CSS-переменные

📂 Категория: CSS

Анимация элементов при скролле — классическая задача, которую часто решают либо тяжелыми библиотеками, либо громоздким кодом. В этой статье мы создадим систему, которая поддерживает Tailwind CSS и корректно работает даже если у пользователя отключен JavaScript.

JavaScript: Intersection Observer

Для отслеживания состояния элементов будем использовать IntersectionObserver. Он не нагружает процессор при скролле и является более подходящим аналогом, чем eventListener.

При загрузке скрипта устанавливаем класс для документа. В дальнейшем будем применять анимацию только, если JavaScript включен. Это позволит поисковым роботам и пользователям, у которых по каким-то причинам выключен JavaScript, видеть неискажённую версию сайта.

document.documentElement.classList.add('js-enabled')

const observerOptions = {
  root: null,
  rootMargin: '0px 0px -10% 0px',
  threshold: 0.2,
}

const animationObserver = new IntersectionObserver(onEntry, observerOptions)
const animationElements = document.querySelectorAll('[data-animation]')

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    document.documentElement.classList.add('anim-ready')
    animationElements.forEach(el => animationObserver.observe(el))
  })
})

function onEntry(entries) {
  entries.forEach(entry => {
    const { target, isIntersecting } = entry
    const shouldRepeat = target.dataset.animationRepeat !== 'false'

    if (isIntersecting) {
      target.dataset.animation = 'active'
      if (!shouldRepeat) animationObserver.unobserve(target)
    } else if (shouldRepeat) target.dataset.animation = ''
  })
}

Вложенные requestAnimationFrame нужны для того, чтобы разделить рендер на 3 этапа:

  1. без анимации
  2. с классом .js-enabled
  3. с классом .js-enabled.anim-ready

Скрипт подразумевает, что мы будем устанавливать атрибут [data-animation] для элементов, которые нужно анимировать.

<div data-animation></div>

И [data-animation-repeat="false"] для тех, у которых анимация будет срабатывать только однократно.

<div data-animation data-animation-repeat="false"></div>
Важно

По умолчанию подразумеваем, что все анимации должны повторяться, если не установлен [data-animation-repeat="false"].

Применение

В упрощённой версии уже можем пользоваться:

.test-elem {
  transition-duration: 0.5s;
}

.test-elem:not([data-animation='active']) {
  opacity: 0;
  transform: translateY(5rem);
}

Или в Tailwind создадим что-то такое через :not

<div
  class='duration-300 [&:not([data-animation="active"])]:translate-y-10 [&:not([data-animation="active"])]:opacity-0'
  data-animation
></div>
Важно

Минус в этом тот, что при выключенном JavaScript элемент в своё нормальное положение не встанет.

Улучшаем компонент

Добавим в наш CSS следующий код:

[data-animation] {
  opacity: 1;
  translate: 0;
  scale: 1;
}

.js-enabled [data-animation] {
  --_op: var(--anim-opacity, 1);
  --_x: var(--anim-translate-x, 0);
  --_y: var(--anim-translate-y, 0);
  --_s: var(--anim-scale, 1);

  opacity: var(--_op);
  translate: var(--_x) var(--_y);
  scale: var(--_s);

  transition: none;
}

.js-enabled.anim-ready [data-animation] {
  transition-property: opacity, translate, scale;
  transition-duration: var(--anim-duration, 0.3s);
  transition-timing-function: ease-out;
  will-change: opacity, translate, scale;
}

.js-enabled.anim-ready [data-animation='active'] {
  --_op: 1;
  --_x: 0;
  --_y: 0;
  --_s: 1;
}

Здесь мы описали часто используемые параметры для анимации, такие как opacity, translateX, translateY, scale и длительность анимации.

Итого, наши заранее подготовленные переменные:

  • --anim-opacity – начальное значение прозрачности
  • --anim-translate-x – смещение по оси X
  • --anim-translate-y – смещение по оси Y
  • --anim-scale – увеличение/уменьшение размера
  • --anim-duration – длительность анимации

Теперь можно использовать такую запись, даже ничего не прописывая в CSS для нашего компонента. В примере: элемент появится снизу из прозрачного состояния за 0.5 секунд.

<div
  data-animation
  style="--anim-duration: 0.5s; --anim-opacity: 0; --anim-translate-y: 5rem"
></div>

Если нам понадобится сделать задержку, то просто добавим к инлайн-стилям style="transition-delay: 1s;".

Пример:

Пример анимации, параграф появляется снизу

Заботимся о тех, кто ненавидит анимации

Добавим в CSS такой сброс. Если пользователь указал в настройках браузера prefers-reduced-motion: reduce, или если устройство не поддерживает частую смену кадров, то устанавливаем все анимации на сайте мгновенными. Эту настройку стоит применять на любый сайтах.

@media screen and (prefers-reduced-motion: reduce), (update: slow) {
  * {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    transition-delay: 0.001ms !important;
  }
}

Итоги

Итого, имеем следующие плюсы:

  • Не используем сторонние библиотеки
  • Используем более быстрый IntersectionObserver вместо eventListener
  • Не пишем классы для изначального состояния для каждого компонента
  • Не показываем анимацию тем, кто её выключил в настройках
  • Если скрипт не заработал, то сайт работает нормально

Для полного счастья не хватает только поддержки добавляемых в DOM новых элементов и staggered-настроек для того, чтобы не перечислять вручную delay у вложенных элементов.

Похожие статьи