Viewport-зависимые анимации в Popmotion строятся вокруг идеи синхронизации движения элементов с положением прокрутки и видимой областью экрана. В отличие от классических таймлайн-анимаций, здесь ключевым фактором становится не время, а позиция: насколько элемент вошёл в область просмотра, где он находится относительно окна браузера и как быстро пользователь скроллит страницу.
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-колбэка не используется из-за затратности. Вместо этого применяется связывание значений.
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);
});
Такой подход позволяет строить сложные нелинейные эффекты: затухания, ускорения, параллакс-движения.
Часто требуется не глобальный 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, что делает его пригодным для анимаций.
Самый распространённый кейс — движение и масштабирование элементов при скролле.
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)`;
});
const scaleMap = interpolate([0, 1], [0.8, 1]);
function onScroll(progress) {
element.style.transform = `scale(${scaleMap(progress)})`;
}
Элемент плавно «раскрывается» по мере появления на экране.
Viewport-анимации редко ограничиваются одним свойством. Обычно одновременно изменяются:
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 выступает как единый источник управления сразу несколькими визуальными параметрами.
Типичная задача — запуск анимации только один раз при появлении элемента.
Логика строится на отслеживании пороговых значений:
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.
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)`;
});
});
Более дальние слои двигаются медленнее, создавая эффект перспективы.
Прямая зависимость progress → значение редко даёт выразительный результат. Используются кривые:
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 позволяет комбинировать такие функции с интерполяцией, создавая сложные поведенческие кривые.
В современных интерфейсах scroll часто происходит не на window, а внутри блока.
const container = document.querySelector(".scroll-container");
container.addEventListener("scroll", () => {
const progress =
container.scrollTop /
(container.scrollHeight - container.clientHeight);
});
Такой подход позволяет создавать независимые scroll-сцены внутри одной страницы.
Сложные интерфейсы часто смешивают два режима:
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-логика чувствительна к производительности. Основные принципы:
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);
});
Здесь отделяется источник данных и побочный эффект, что упрощает масштабирование анимационной логики.
Сложные интерфейсы строятся из нескольких независимых слоёв:
scroll(p => {
header.style.opacity = p;
background.style.transform = `translateY(${p * 100}px)`;
card.style.transform = `scale(${0.9 + p * 0.1})`;
});
Такая композиция делает viewport не просто триггером, а полноценной системой управления сценой.