Привязка к скроллу страницы

Работа со скролл-зависимой анимацией в контексте Lottie Web строится вокруг идеи синхронизации прогресса прокрутки страницы и текущего кадра анимации. В отличие от автономного воспроизведения, где управление временем полностью находится внутри движка анимации, здесь внешний источник времени — положение страницы в окне браузера.

Принцип синхронизации скролла и анимации

Базовая модель управления сводится к отображению:

  • текущей позиции скролла
  • в диапазон прогресса анимации 0 → 1

Это линейное преобразование, но в реальных сценариях почти всегда применяется нормализация относительно конкретного блока интерфейса.

Формально:

progress = (scrollY - elementTop) / elementHeight

Где:

  • scrollY — текущая позиция прокрутки окна
  • elementTop — верхняя граница секции
  • elementHeight — высота активной области анимации

Полученное значение ограничивается диапазоном:

progress = Math.min(Math.max(progress, 0), 1);

И далее передаётся в Lottie:

animation.goToAndStop(progress * animation.totalFrames, true);

Инициализация Lottie-анимации

Перед привязкой к скроллу анимация должна быть загружена в режиме ручного управления временем:

const animation = lottie.loadAnimation({
  container: document.getElementById('lottie'),
  renderer: 'svg',
  loop: false,
  autoplay: false,
  path: '/animations/example.json'
});

Ключевые параметры:

  • loop: false — отключает автоматическое зацикливание
  • autoplay: false — исключает самостоятельный запуск
  • renderer: 'svg' — обеспечивает масштабируемость и точность кадров

В таком режиме Lottie Web полностью подчиняется внешнему контроллеру.


Базовая привязка к событию scroll

Самый прямолинейный способ — обработка события прокрутки:

window.addEventListener('scroll', () => {
  const scrollTop = window.scrollY;

  const element = document.getElementById('section');
  const offsetTop = element.offsetTop;
  const height = element.offsetHeight;

  let progress = (scrollTop - offsetTop) / height;
  progress = Math.min(Math.max(progress, 0), 1);

  animation.goToAndStop(progress * animation.totalFrames, true);
});

Недостаток подхода заключается в высокой частоте вызовов обработчика и потенциальных проблемах производительности при сложных страницах.


Оптимизация через requestAnimationFrame

Чтобы избежать перегрузки main thread, синхронизация переносится в цикл рендеринга браузера:

let latestScroll = 0;
let ticking = false;

window.addEventListener('scroll', () => {
  latestScroll = window.scrollY;

  if (!ticking) {
    window.requestAnimationFrame(update);
    ticking = true;
  }
});

function update() {
  const element = document.getElementById('section');
  const offsetTop = element.offsetTop;
  const height = element.offsetHeight;

  let progress = (latestScroll - offsetTop) / height;
  progress = Math.min(Math.max(progress, 0), 1);

  animation.goToAndStop(progress * animation.totalFrames, true);

  ticking = false;
}

Этот подход синхронизирует вычисления с частотой обновления экрана и устраняет лишние вызовы логики.


Привязка к конкретному диапазону скролла

В реальных интерфейсах анимация часто должна проигрываться не на всей длине страницы, а внутри ограниченного диапазона.

Для этого задаются:

  • стартовая позиция секции
  • длительность активного скролл-участка
const start = section.offsetTop;
const duration = 800; // пиксели прокрутки

function calculateProgress(scrollY) {
  return (scrollY - start) / duration;
}

Это позволяет точно контролировать момент начала и окончания анимации независимо от контента ниже.


Использование Intersection Observer для активации логики

Для оптимизации можно комбинировать scroll-логику с наблюдением видимости:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    isActive = entry.isIntersecting;
  });
}, {
  threshold: 0
});

observer.observe(document.getElementById('section'));

Далее обновление прогресса выполняется только при активной секции:

if (isActive) {
  animation.goToAndStop(progress * animation.totalFrames, true);
}

Это снижает нагрузку при длинных страницах с множеством анимаций.


Разбиение скролла на сегменты

Для сложных сцен используется сегментация анимации. Каждому участку скролла соответствует свой диапазон кадров.

Пример:

  • 0–300px → кадры 0–120
  • 300–600px → кадры 120–240
  • 600–900px → кадры 240–360
function mapRange(value, inMin, inMax, outMin, outMax) {
  return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}

Использование:

let frame;

if (scrollY < 300) {
  frame = mapRange(scrollY, 0, 300, 0, 120);
} else if (scrollY < 600) {
  frame = mapRange(scrollY, 300, 600, 120, 240);
} else {
  frame = mapRange(scrollY, 600, 900, 240, 360);
}

animation.goToAndStop(frame, true);

Такой подход применяется в сторителлинговых интерфейсах и лендингах с последовательной визуальной логикой.


Плавность и инерция движения

Прямое сопоставление scroll → frame даёт резкое поведение. Для сглаживания вводится интерполяция:

let current = 0;

function smooth(target) {
  current += (target - current) * 0.1;
  return current;
}

Использование:

const targetFrame = progress * animation.totalFrames;
const frame = smooth(targetFrame);

animation.goToAndStop(frame, true);

Это создаёт эффект инерции, особенно заметный на длинных анимациях.


Связь скорости скролла и скорости анимации

Помимо позиции, можно учитывать скорость прокрутки:

let lastScroll = 0;
let velocity = 0;

window.addEventListener('scroll', () => {
  velocity = window.scrollY - lastScroll;
  lastScroll = window.scrollY;
});

Далее скорость может влиять на коэффициент прогресса:

progress += velocity * 0.002;

Так создаются эффекты ускоренного воспроизведения при активном скролле и замедления при остановке.


Синхронизация нескольких Lottie-анимаций

При работе с несколькими слоями анимаций важно избегать рассинхронизации:

const animations = [anim1, anim2, anim3];

animations.forEach(anim => {
  anim.goToAndStop(frame, true);
});

Для более сложных сцен применяется общий контроллер прогресса, который распределяет кадры между независимыми композициями.


Типичные проблемы привязки к скроллу

Основные источники нестабильности:

  • пересчёт offsetTop при изменении layout
  • отсутствие учёта resize окна
  • конфликт нескольких scroll listener
  • высокая частота DOM-запросов

Корректная архитектура предполагает кэширование геометрии:

let cachedTop = section.offsetTop;
let cachedHeight = section.offsetHeight;

window.addEventListener('resize', () => {
  cachedTop = section.offsetTop;
  cachedHeight = section.offsetHeight;
});

Использование фиксированного контейнера

Для создания эффекта «закреплённой» анимации элемент фиксируется в viewport, а скролл управляет только логикой:

.section {
  position: sticky;
  top: 0;
  height: 100vh;
}

В таком случае Lottie становится визуальным слоем, полностью управляемым внешним прогрессом, без смещения по DOM.


Преобразование скролла в нелинейные кривые

Линейное отображение часто заменяется функциями easing:

function easeInOut(t) {
  return t < 0.5
    ? 2 * t * t
    : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

Использование:

progress = easeInOut(progress);
animation.goToAndStop(progress * animation.totalFrames, true);

Это позволяет создавать более кинематографичную динамику при прокрутке.


Архитектура скролл-контроллера

В продвинутых реализациях вводится отдельный слой управления:

  • модуль получения scroll position
  • модуль нормализации прогресса
  • модуль mapping → frame
  • модуль отправки в Lottie Web

Такая декомпозиция позволяет масштабировать систему на десятки анимационных секций без потери производительности и предсказуемости поведения.