Viewport based анимации

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

Viewport можно рассматривать как динамическую систему координат, которая постоянно изменяется при прокрутке. Любая точка на странице получает относительное положение:

  • выше видимой области
  • внутри viewport
  • ниже видимой области

Анимации в таком подходе строятся на преобразовании этих координат в значения свойств: прозрачность, масштаб, смещение, вращение.

Popmotion предоставляет инструменты низкого уровня для создания таких связей: реактивные значения, подписки на изменения и функции преобразования (transformers), которые позволяют гибко связывать scroll-позицию с любыми CSS-свойствами.

Базовая модель привязки к скроллу

Ключевой принцип: поток данных от scroll → функция преобразования → стиль элемента.

В упрощённом виде это выглядит так:

import { scroll } from "popmotion";

const tracker = scroll(progress => {
  console.log(progress);
});

Значение progress нормализуется в диапазон от 0 до 1 и отражает позицию прокрутки относительно всей страницы или заданного контейнера.

Дальше это значение становится управляющим параметром для анимаций:

import { animate } from "popmotion";

scroll(y => {
  animate({
    from: 0,
    to: 1,
    onUpdate: v => {
      element.style.opacity = v;
    }
  });
});

На практике прямое создание анимации внутри scroll-колбэка не используется из-за затратности. Вместо этого применяется связывание значений.

Интерполяция значений через transform pipeline

Popmotion активно использует функциональные преобразования. Вместо императивного изменения стилей применяется маппинг значений.

import { transform } from "popmotion";

const { interpolate } = transform;

const opacityMap = interpolate([0, 0.5, 1], [0, 1, 0]);

Теперь scroll-значение можно напрямую преобразовать:

scroll(y => {
  element.style.opacity = opacityMap(y);
});

Такой подход позволяет строить сложные нелинейные эффекты: затухания, ускорения, параллакс-движения.

Привязка анимации к положению элемента в viewport

Часто требуется не глобальный scroll, а локальная позиция элемента относительно viewport. Это позволяет запускать анимацию только тогда, когда блок входит в область видимости.

Логика строится на вычислении bounding rectangle:

const upd ate = () => {
  const rect = element.getBoundingClientRect();
  const viewportHeight = window.innerHeight;

  const progress = 1 - (rect.top / viewportHeight);
};

Полученное значение нормализуется и используется как управляющий сигнал.

Использование нормализации диапазона

Чтобы избежать резких скачков значений, применяется ограничение диапазона:

const clamp = (min, max) => v => Math.min(Math.max(v, min), max);

const progress = clamp(0, 1)(
  1 - rect.top / viewportHeight
);

Теперь progress всегда находится в пределах 0–1, что делает его пригодным для анимаций.

Связывание viewport-позиции с трансформациями

Самый распространённый кейс — движение и масштабирование элементов при скролле.

Параллакс-смещение

scroll(y => {
  const translateY = y * 200;
  element.style.transform = `translateY(${translateY}px)`;
});

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

const offsetY = interpolate([0, 1], [0, 300]);

scroll(y => {
  element.style.transform = `translateY(${offsetY(y)}px)`;
});

Масштабирование при входе в viewport

const scaleMap = interpolate([0, 1], [0.8, 1]);

function onScroll(progress) {
  element.style.transform = `scale(${scaleMap(progress)})`;
}

Элемент плавно «раскрывается» по мере появления на экране.

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

Viewport-анимации редко ограничиваются одним свойством. Обычно одновременно изменяются:

  • opacity
  • translateY
  • scale
  • blur (через filter)
const opacity = interpolate([0, 0.3, 1], [0, 1, 1]);
const y = interpolate([0, 1], [50, 0]);
const scale = interpolate([0, 1], [0.95, 1]);

scroll(progress => {
  const o = opacity(progress);
  const ty = y(progress);
  const s = scale(progress);

  element.style.transform = `translateY(${ty}px) scale(${s})`;
  element.style.opacity = o;
});

Здесь viewport выступает как единый источник управления сразу несколькими визуальными параметрами.

Анимации при входе и выходе из viewport

Типичная задача — запуск анимации только один раз при появлении элемента.

Логика строится на отслеживании пороговых значений:

let triggered = false;

function onScroll() {
  const rect = element.getBoundingClientRect();
  const visible = rect.top < window.innerHeight && rect.bottom > 0;

  if (visible && !triggered) {
    triggered = true;

    animate({
      from: 0,
      to: 1,
      duration: 600,
      onUpdate: v => {
        element.style.opacity = v;
        element.style.transform = `translateY(${20 * (1 - v)}px)`;
      }
    });
  }
}

Здесь viewport выступает триггером, а Popmotion управляет плавностью перехода.

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

Более точный подход — вычисление доли видимой части элемента:

function getVisibility(el) {
  const rect = el.getBoundingClientRect();

  const visibleHeight =
    Math.min(rect.bottom, window.innerHeight) -
    Math.max(rect.top, 0);

  return visibleHeight / rect.height;
}

Теперь можно создавать эффекты, зависящие от степени «погружения» элемента в viewport.

Использование spring-физики для scroll-эффектов

Popmotion позволяет заменять линейные интерполяции физическими моделями движения.

import { spring } from "popmotion";

scroll(progress => {
  spring({
    from: elementY,
    to: progress * 100,
    stiffness: 200,
    damping: 20,
    onUpdate: v => {
      element.style.transform = `translateY(${v}px)`;
    }
  });
});

Такая модель добавляет инерцию: элемент не следует за scroll мгновенно, а догоняет его.

Параллакс-слои с разной глубиной

Viewport-анимации часто используются для создания ощущения глубины.

const layers = document.querySelectorAll(".layer");

scroll(progress => {
  layers.forEach((layer, i) => {
    const depth = (i + 1) * 50;
    layer.style.transform = `translateY(${progress * depth}px)`;
  });
});

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

Линейные и нелинейные кривые scroll-поведения

Прямая зависимость progress → значение редко даёт выразительный результат. Используются кривые:

  • ускорение в начале
  • замедление в конце
  • S-образные переходы
const easeInOut = t =>
  t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;

scroll(progress => {
  const eased = easeInOut(progress);
  element.style.opacity = eased;
});

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

Контейнерный viewport вместо window

В современных интерфейсах scroll часто происходит не на window, а внутри блока.

const container = document.querySelector(".scroll-container");

container.addEventListener("scroll", () => {
  const progress =
    container.scrollTop /
    (container.scrollHeight - container.clientHeight);
});

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

Комбинирование viewport и time-based анимаций

Сложные интерфейсы часто смешивают два режима:

  • scroll управляет состоянием
  • animate/spring управляет переходами
scroll(progress => {
  const target = progress > 0.5 ? 1 : 0;

  animate({
    from: current,
    to: target,
    duration: 300,
    onUpdate: v => {
      element.style.opacity = v;
    }
  });
});

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

Оптимизация viewport-анимаций

Viewport-логика чувствительна к производительности. Основные принципы:

  • минимизация вычислений в scroll handler
  • использование requestAnimationFrame
  • отказ от частого пересоздания анимаций
  • кеширование boundingClientRect при необходимости
let ticking = false;

window.addEventListener("scroll", () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      update();
      ticking = false;
    });

    ticking = true;
  }
});

Декларативная модель через реактивные значения

Popmotion поддерживает подход, при котором scroll становится источником реактивного состояния.

import { value } from "popmotion";

const y = value(0, v => {
  element.style.transform = `translateY(${v}px)`;
});

scroll(progress => {
  y.se t(progress * 200);
});

Здесь отделяется источник данных и побочный эффект, что упрощает масштабирование анимационной логики.

Композиция viewport-эффектов

Сложные интерфейсы строятся из нескольких независимых слоёв:

  • один отвечает за появление
  • второй за параллакс
  • третий за изменение цвета
scroll(p => {
  header.style.opacity = p;
  background.style.transform = `translateY(${p * 100}px)`;
  card.style.transform = `scale(${0.9 + p * 0.1})`;
});

Такая композиция делает viewport не просто триггером, а полноценной системой управления сценой.