Веб-анимация: GSAP, Lenis и Three.js
Движение, которое ощущается плавным, не тормозит и уважает тех, кто предпочитает его поменьше.
За 30 секунд
Хорошая веб-анимация направляет внимание и делает интерфейс живым; плохая делает его медленным и раздражающим. GSAP — стандартная JavaScript-библиотека для таймлайнов и анимаций, привязанных к скроллу, Lenis добавляет плавную прокрутку, а Three.js рисует 3D через WebGL. Самые важные правила просты: анимируйте transform и opacity, убирайте за собой и всегда уважайте prefers-reduced-motion.
Почему я это использую
- Я использую GSAP, потому что его таймлайны делают сложную последовательную анимацию читаемой и легко изменяемой, а ScrollTrigger надёжно работает с эффектами, привязанными к скроллу, во всех браузерах.
- Я добавляю Lenis на маркетинговые и имиджевые сайты, где плавная, предсказуемая прокрутка — часть впечатления, и связываю его со ScrollTrigger, чтобы оба одинаково понимали позицию скролла.
- Я беру Three.js, когда бренду нужен настоящий 3D-момент, например интерактивная сцена или игривая мини-игра, и загружаю его лениво, чтобы он никогда не блокировал страницу.
- Я отношусь к производительности и reduced motion как к требованиям, потому что анимация, которая теряет кадры или вызывает у кого-то головокружение, — это баг, а не фича.
Как это работает
01Как браузер рисует кадр
Чтобы хорошо анимировать, нужно понимать, что браузер делает 60 и более раз в секунду. Для каждого кадра он может выполнить JavaScript, пересчитать стили, сделать layout (вычислить размеры и позиции), paint (закрасить пиксели) и composite (сложить слои на GPU). Изменение width, height, top или margin вызывает layout и paint — это дорого. Изменение transform и opacity обычно позволяет пропустить оба этапа и выполняется только в композиторе — это дёшево. Поэтому плавные анимации двигают элементы через translate, scale и rotate, а растворяют через opacity. При 60Hz на кадр у вас около 16 миллисекунд, а на экранах 120Hz ещё меньше, так что каждый лишний layout вам дорого обходится.
- Дёшево: transform (translate, scale, rotate), opacity, во многих случаях filter.
- Дорого: width, height, top, left, margin, padding, размер box-shadow.
- Layout thrash: чтение layout (offsetHeight) сразу после записи стилей, в цикле.
02Основы GSAP и таймлайны
GSAP анимирует любое числовое свойство во времени. Tween — это одна анимация: gsap.to() анимирует к значениям, gsap.from() — от значений к текущему состоянию, а gsap.fromTo() задаёт оба конца. Таймлайн — это последовательность твинов с точным контролем наложений через position-параметр: '<' запускает вместе с предыдущим твином, '-=0.2' — с наложением на 0.2 секунды. Таймлайны можно ставить на паузу, разворачивать, ускорять и прокручивать (scrub), поэтому они идеальны для интро и скролл-историй. С 2025 года GSAP и все его плагины, включая SplitText и ScrollTrigger, бесплатны для коммерческого использования. В React используйте хук useGSAP из @gsap/react: он ограничивает селекторы контейнером и откатывает все анимации при размонтировании.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export function Intro() {
const root = useRef<HTMLDivElement>(null);
useGSAP(() => {
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.8 } });
tl.from(".title", { y: 40, autoAlpha: 0 })
.from(".lead", { y: 20, autoAlpha: 0 }, "-=0.5")
.from(".cta", { scale: 0.9, autoAlpha: 0 }, "<0.2");
}, { scope: root });
return <div ref={root}>{/* .title .lead .cta */}</div>;
}
03ScrollTrigger и SplitText
ScrollTrigger связывает анимацию с позицией скролла. Вы задаёте элемент-триггер и точки start и end, например 'top 80%' (когда верх элемента доходит до 80% высоты вьюпорта). С scrub: true анимация следует за полосой прокрутки; с pin: true элемент остаётся зафиксированным, пока играет таймлайн. Во время разработки включайте markers: true. SplitText разбивает текст на строки, слова или символы, чтобы анимировать их со stagger. Переписанная в 2025 году версия меньше по размеру, заново разбивает текст при ресайзе через autoSplit, умеет оборачивать строки в маски для эффектов появления и добавляет aria-label родителю, скрывая разбитые кусочки от скринридеров. Всегда дожидайтесь загрузки шрифтов перед разбиением, иначе переносы строк будут неверными.
gsap.registerPlugin(ScrollTrigger, SplitText);
document.fonts.ready.then(() => {
SplitText.create(".headline", {
type: "lines",
mask: "lines",
autoSplit: true,
onSplit: (self) =>
gsap.from(self.lines, {
yPercent: 100,
stagger: 0.08,
scrollTrigger: { trigger: ".headline", start: "top 80%" },
}),
});
});
04Плавная прокрутка с Lenis
Lenis — небольшая библиотека, которая сглаживает прокрутку колесом и тачпадом, сохраняя нативное поведение скролла: страница по-прежнему прокручивает документ, поэтому position: sticky, якоря и инструменты доступности продолжают работать. Ключевая точка интеграции — ScrollTrigger: оба должны использовать одни и те же часы и одну и ту же позицию скролла. Вы запускаете Lenis от тикера GSAP и говорите ScrollTrigger обновляться на каждое событие скролла Lenis. Отключите lag smoothing в GSAP, чтобы они никогда не расходились. Осторожнее с вложенными областями прокрутки (модалки, выпадающие списки): помечайте их data-lenis-prevent, чтобы они скроллились нативно. Уважайте reduced motion: для таких пользователей вообще не запускайте Lenis.
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
const lenis = new Lenis({ lerp: 0.1 });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
}
05Основы Three.js
Three.js — библиотека поверх WebGL (а теперь и WebGPU) для 3D в браузере. Каждой сцене нужны четыре вещи: Scene, которая хранит объекты, Camera, которая на неё смотрит, Renderer, который рисует в canvas, и Mesh — это геометрия (форма) плюс материал (как она выглядит). Для большинства материалов нужен свет. Цикл анимации обновляет объекты и рендерит каждый кадр. Для производительности ограничьте pixel ratio значением 2, переиспользуйте геометрии и материалы, ставьте рендеринг на паузу, когда canvas вне экрана, и вызывайте dispose() для геометрий, материалов и текстур при размонтировании компонента, иначе утекает память GPU. В React-проектах React Three Fiber оборачивает те же идеи в компоненты.
import * as THREE from "three";
const canvas = document.querySelector("canvas")!;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
camera.position.z = 4;
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), new THREE.MeshNormalMaterial());
scene.add(mesh);
renderer.setAnimationLoop((t) => {
mesh.rotation.y = t * 0.0004;
renderer.render(scene, camera);
});
06Производительность: transform, will-change и layout thrash
Большинство дёрганых анимаций имеют три причины. Первая — анимация свойств раскладки; переходите на transform (x, y, scale и rotation в GSAP уже используют transform). Вторая — layout thrash: код, который записывает стиль, затем читает offsetHeight или getBoundingClientRect, затем снова записывает, заставляя браузер многократно пересчитывать layout в пределах одного кадра. Сначала соберите все чтения, потом все записи. Третья — слишком много слоёв или огромная перерисовываемая область. will-change: transform выносит элемент на собственный GPU-слой, что помогает элементу, который вот-вот начнёт анимироваться, но каждый слой стоит памяти, поэтому добавляйте его незадолго до анимации и убирайте после, и никогда — на сотни элементов. Профилируйте в панели Performance в Chrome DevTools: длинные фиолетовые (layout) и зелёные (paint) блоки показывают проблему.
- В GSAP анимируйте x, y, scale, rotation и autoAlpha.
- Сначала прочитайте все замеры, потом записывайте стили.
- Используйте will-change экономно и временно.
- Убивайте ScrollTrigger'ы и твины при смене маршрута, чтобы избежать утечек.
07Reduced motion и доступность
У некоторых людей от сильного движения, параллакса и зума кружится голова или начинается тошнота. Они включают «уменьшение движения» в операционной системе, а браузер отдаёт это как media query prefers-reduced-motion. Уважайте её: оставляйте растворения и смену цвета, но убирайте крупные перемещения, параллакс, автоматически играющие циклы и перехват прокрутки. gsap.matchMedia() позволяет задавать анимации под каждое условие и автоматически откатывает их, когда условие меняется. Не менее важно: страница должна быть полноценной без JavaScript. Контент, скрытый в CSS в ожидании анимации появления, должен иметь видимое конечное состояние, если скрипт не загрузился. Никогда не уводите фокус анимацией, давайте ставить на паузу всё, что движется дольше пяти секунд (требование WCAG), и избегайте мигания чаще трёх раз в секунду.
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.from(".card", { y: 60, autoAlpha: 0, stagger: 0.1,
scrollTrigger: { trigger: ".cards", start: "top 75%" } });
});
mm.add("(prefers-reduced-motion: reduce)", () => {
gsap.from(".card", { autoAlpha: 0, duration: 0.3 });
});
Когда применять
- Имиджевые, агентские и промо-сайты, где движение — часть истории.
- Последовательные интро, переходы между страницами и сторителлинг на скролле.
- Микровзаимодействия, которые объясняют смену состояния, например добавление в корзину или открытие панели.
- Интерактивные 3D-просмотры товара или игривые сцены, когда бренд оправдывает вес.
Когда не стоит
- Простые состояния hover и focus; CSS-переходы легче, и их достаточно.
- Дашборды с большим количеством данных и админки, где анимация замедляет людей.
- Плавная прокрутка в приложениях с большим количеством контента или форм, где ожидается нативный скролл.
Альтернативы
- CSS transitions и keyframes
Ноль JavaScript, отлично подходят для hover-состояний и простых появлений.
- CSS scroll-driven animations
animation-timeline: view() и scroll() работают вне главного потока; поддержка браузерами ещё растёт, так что держите fallback.
- View Transitions API
Нативные анимированные переходы между состояниями DOM и страницами, уже применимые в современных браузерах.
- Motion (бывший Framer Motion)
Декларативная анимация в React с layout-анимациями; удобна в приложениях, построенных на компонентах.
Лучшие практики
- Анимируйте transform и opacity; избегайте свойств раскладки.
- Используйте таймлайны с defaults, чтобы движение оставалось единообразным и легко настраивалось.
- Ограничивайте область и очищайте анимации через useGSAP или context.revert() при размонтировании.
- Подключайте Lenis и ScrollTrigger к одному тикеру.
- Уважайте prefers-reduced-motion через gsap.matchMedia().
- Давайте каждому анимируемому элементу видимое конечное состояние без JavaScript.
- Загружайте сцены Three.js лениво и освобождайте ресурсы GPU при размонтировании.
Частые ошибки
- Анимировать top, left или width и удивляться, почему на телефонах всё дёргается.
- Скрывать контент через opacity: 0 в CSS, из-за чего он остаётся невидимым, если скрипт упал.
- Забывать убивать ScrollTrigger'ы при клиентской навигации, что приводит к неверным позициям и утечкам.
- Разбивать текст до загрузки веб-шрифтов, из-за чего переносы строк сдвигаются уже после старта анимации.
- Ставить will-change на всё подряд — это расходует больше памяти и может всё замедлить.
Глубже
08Слои композитора: что на самом деле делает GPU
Когда вы анимируете transform или opacity, браузер может двигать элемент в потоке композитора, отдельно от главного потока, где выполняется JavaScript. Для этого элементу нужен собственный слой — битмап в памяти GPU. Браузеры повышают элементы до слоя автоматически во время CSS-переходов и transform-анимаций Web Animations API или когда вы задаёте will-change: transform. Каждый слой стоит примерно ширина на высоту на четыре байта, умноженные на квадрат device pixel ratio, поэтому один полноэкранный слой на телефоне с 3x уже занимает больше десяти мегабайт. Повысьте до слоя пару сотен карточек — и мобильные браузеры начинают терять кадры или даже перезагружают вкладку. Обратите внимание на важную разницу: CSS-анимации и анимации transform через Web Animations API могут продолжаться, даже когда главный поток занят, а анимации на JavaScript, включая GSAP, считают каждый кадр в главном потоке, поэтому длинные задачи заставляют их дёргаться. Что повышено до слоя, смотрите в панели Layers в DevTools и через границы слоёв на вкладке Rendering.
- Память слоя растёт с площадью и квадратом pixel ratio, а не только с количеством элементов.
- Перекрытие повышенного слоя может принудительно вынести в слои и другие элементы (layer explosion).
- Для эффектов, которые должны пережить загруженный главный поток, выбирайте CSS или Web Animations API.
09Высокочастотное движение: quickTo, overwrite и ticker
Некоторое движение обновляется десятки раз в секунду: курсор-последователь, магнитная кнопка, параллакс, реагирующий на указатель. Создание нового gsap.to() на каждый pointermove выделяет объекты и порождает твины, которые воюют друг с другом. gsap.quickTo() строит одну переиспользуемую функцию на свойство, которая просто перенацеливает один твин, и это намного дешевле. Когда одно и то же свойство могут анимировать несколько твинов, задайте overwrite: 'auto', чтобы GSAP убивал только пересекающуюся часть старых твинов. Вся анимация GSAP работает на одном ticker, построенном на requestAnimationFrame; подключайте свою покадровую логику через gsap.ticker.add(), а не запускайте второй цикл, чтобы всё оставалось синхронным и вставало на паузу вместе. GSAP также применяет lag smoothing после длинного кадра, чтобы движение не прыгало. Внутри таких обработчиков никогда не читайте layout; кэшируйте размеры элементов при resize через ResizeObserver и только записывайте transform.
import gsap from "gsap";
const xTo = gsap.quickTo(".cursor", "x", { duration: 0.4, ease: "power3" });
const yTo = gsap.quickTo(".cursor", "y", { duration: 0.4, ease: "power3" });
window.addEventListener(
"pointermove",
(e) => {
xTo(e.clientX);
yTo(e.clientY);
},
{ passive: true },
);
// one shared per-frame hook instead of a separate requestAnimationFrame loop
gsap.ticker.add((time, deltaTime) => {
/* update custom state with deltaTime (ms) */
});
10Пограничные случаи ScrollTrigger, которые ломаются в продакшене
ScrollTrigger вычисляет позиции start и end и кэширует их, поэтому всё, что позже меняет высоту страницы, заставляет триггеры срабатывать не там: изображения без размеров, подмена веб-шрифтов, контент, загруженный на клиенте, раскрывающиеся аккордеоны. Резервируйте место под медиа и вызывайте ScrollTrigger.refresh() после таких изменений; при load и resize он уже запускается сам. Создавайте триггеры в том порядке, в котором они идут на странице, или задавайте refreshPriority, потому что пиннинг секции добавляет отступ, который сдвигает всё ниже. На телефонах появление и скрытие адресной строки меняет размер viewport; ScrollTrigger.config({ ignoreMobileResize: true }) пропускает refresh при таких вертикальных resize, а высоты в единицах svh остаются стабильными. Пиннинг внутри родителя с transform работает неправильно, потому что position: fixed становится относительным любому предку с transform; пиньте внешнюю секцию. В одностраничной навигации убивайте старые триггеры до создания новых, иначе позиции с предыдущей страницы протекают на следующую.
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({ ignoreMobileResize: true });
// recalc once fonts and late content are in place
document.fonts.ready.then(() => ScrollTrigger.refresh());
new ResizeObserver(() => ScrollTrigger.refresh()).observe(
document.querySelector("main")!,
);
11Three.js на масштабе: draw calls, шейдеры и WebGPU
3D-сцену обычно ограничивают draw calls и работа шейдеров, а не количество треугольников. Каждый меш со своим материалом — минимум один draw call, поэтому тысяча одинаковых объектов отдельными мешами стоит тысячу вызовов; один InstancedMesh рисует их все за один вызов с матрицей на каждый экземпляр. Объединяйте статическую геометрию и разделяйте материалы, чтобы GPU реже переключал состояние. Первый кадр, использующий новый материал, компилирует его шейдер в главном потоке, и это может заморозить страницу на сотни миллисекунд; прекомпилируйте через renderer.compileAsync() до показа сцены. Для сцен, которые меняются только при взаимодействии, рендерите по требованию, а не каждый кадр (в React Three Fiber — frameloop='demand'). Следите за renderer.info: живые draw calls, треугольники, геометрии и текстуры; если числа растут от навигации к навигации, это утечка. Three.js также поставляет WebGPURenderer, который откатывается на WebGL 2 и даёт compute-шейдеры, но обычный WebGL остаётся самым безопасным выбором, когда важна широкая поддержка устройств.
import * as THREE from "three";
const count = 2000;
const mesh = new THREE.InstancedMesh(
new THREE.SphereGeometry(0.05, 12, 12),
new THREE.MeshStandardMaterial({ color: 0xdddddd }),
count,
);
const m = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
m.setPosition(Math.random() * 10 - 5, Math.random() * 10 - 5, Math.random() * -10);
mesh.setMatrixAt(i, m);
}
scene.add(mesh); // 2,000 spheres, one draw call
await renderer.compileAsync(scene, camera); // avoid first-frame shader jank
console.log(renderer.info.render.calls);
Реальные ситуации
Запиненная секция прыгает и смещается на iPhone
Проблема
Запиненная сторителлинг-секция работает на десктопе, но на iPhone прыгает, когда сворачивается адресная строка, а иногда отпинивается слишком рано после загрузки изображений.
Как решить
Сначала воспроизведите проблему на реальном устройстве через удалённый Web Inspector в Safari и markers: true. Затем устраняйте причины по одной. Задайте каждому изображению width и height или aspect-ratio, чтобы поздние загрузки не меняли высоту страницы. Вызывайте ScrollTrigger.refresh() после document.fonts.ready и после рендера контента, загруженного на клиенте. Задайте ScrollTrigger.config({ ignoreMobileResize: true }) и размеряйте полноэкранные панели через 100svh вместо 100vh. Проверьте, что у предков запиненного элемента нет transform, filter или will-change, которые ломают фиксированное позиционирование. И наконец, при prefers-reduced-motion полностью пропускайте пин через gsap.matchMedia() и показывайте шаги обычным вертикальным списком.
INP ухудшился после запуска нового анимированного hero
Проблема
Полевой INP на средних Android-телефонах вырос примерно со 150 мс до более чем 300 мс после нового hero со split text, множеством скролл-эффектов и небольшой 3D-сценой. Сама анимация выглядит плавно.
Как решить
Запишите трейс в DevTools с 4x замедлением CPU и покликайте во время загрузки; ищите длинные задачи, пересекающиеся со взаимодействиями. Обычные виновники: SplitText, который разбивает сотни символов в одной задаче, десятки ScrollTrigger, созданных разом, компиляция шейдеров при первом рендере, обработчики pointermove, читающие layout. Разбивайте по словам или строкам вместо символов, создавайте эффекты ниже первого экрана только тогда, когда IntersectionObserver сообщает, что они близко, и уступайте поток между частями настройки через scheduler.yield(), где он поддерживается. Прекомпилируйте 3D-сцену через compileAsync и лениво загружайте Three.js после того, как страница стала интерактивной. Подтвердите результат в поле через attribution-сборку web-vitals, которая называет медленное взаимодействие и скрипт.
Память растёт при каждой клиентской навигации
Проблема
В портфолио на Next.js после просмотра десяти кейсов вкладка занимает больше гигабайта, а скролл дёргается. На каждой странице есть canvas с Three.js и скролл-анимации.
Как решить
Снимите heap snapshots в панели Memory в DevTools до и после нескольких навигаций и сравните отсоединённые DOM-узлы и удерживаемые объекты WebGL. При размонтировании откатывайте GSAP через useGSAP, вызывайте lenis.destroy(), если каждая страница создаёт Lenis, останавливайте цикл через renderer.setAnimationLoop(null), обходите сцену, освобождая геометрии, материалы и текстуры, затем вызывайте renderer.dispose(). Отключайте ResizeObserver и удаляйте слушатели window. Браузеры ограничивают количество живых контекстов WebGL и с предупреждением отбрасывают самый старый, поэтому архитектура получше — один постоянный canvas в корневом layout, который меняет сцены между роутами, вместо нового рендерера на каждой странице.
Шпаргалка
- Дёшево перенацелить одно свойство
const xTo = gsap.quickTo(el, "x", { duration: 0.3 }); xTo(120);- Не дать пересекающимся твинам воевать
gsap.to(el, { x: 100, overwrite: "auto" });- Пересчитать позиции триггеров
ScrollTrigger.refresh();- Игнорировать resize от адресной строки на мобильных
ScrollTrigger.config({ ignoreMobileResize: true });- Замедлить все анимации для отладки
gsap.globalTimeline.timeScale(0.2);- Убить все триггеры при смене роута
ScrollTrigger.getAll().forEach((t) => t.kill());- Остановить цикл рендера Three.js
renderer.setAnimationLoop(null);- Прекомпилировать шейдеры до показа сцены
await renderer.compileAsync(scene, camera);- Посчитать draw calls за кадр
renderer.info.render.calls- Рендерить только при изменениях (R3F)
<Canvas frameloop="demand">
В моих реальных проектах
- Я использую GSAP в нескольких проектах на Next.js, включая сайт заказов сети ресторанов, магазин зоотоваров, магазин винодельни и сайт digital-агентства.
- При переписывании сайта агентства фронтенд сочетал GSAP с мини-игрой на three.js, курсором из частиц и Lottie-анимациями, а также тёмной темой.
- Сайт прямых продаж винодельни, над которым я работал, включает 3D-сцену на Three.js.
- Этот сайт использует GSAP со ScrollTrigger, SplitText и Flip плюс Lenis, управляемые через data-атрибуты, так что серверные компоненты никогда не импортируют GSAP; reduced motion учитывается, а у каждого эффекта есть видимое конечное состояние.
Вопросы и ответы
Q1Почему нужно анимировать transform и opacity, а не top или width?
Из-за конвейера рендеринга. Изменение top, left, width или margin меняет геометрию, поэтому браузер должен пересчитать layout, перерисовать пиксели и затем выполнить композитинг — часто для многих элементов и в каждом кадре. Transform и opacity обычно обрабатываются композитором на GPU без layout и paint, поэтому остаются плавными даже на телефонах среднего уровня. На практике я двигаю элементы через translate и scale, растворяю через opacity, а если действительно нужно изменить размер — анимирую scale и корректирую содержимое или использую технику вроде FLIP, которая один раз замеряет начальную и конечную раскладку и анимирует разницу как transform.
Q2Что такое layout thrashing и как его избежать?
Layout thrashing возникает, когда код чередует запись стилей и чтение значений раскладки в одном кадре. Например, цикл, который задаёт высоту элемента, а затем читает offsetHeight следующего. Каждое чтение заставляет браузер синхронно пересчитать layout, чтобы вернуть правильное число. При большом количестве элементов это превращается в десятки принудительных пересчётов за кадр. Решение — группировать: сначала прочитать все замеры и сохранить их, потом применить все записи. Библиотеки анимации вроде GSAP уже группируют это внутри, а requestAnimationFrame помогает планировать записи. Панель Performance в DevTools помечает принудительные reflow, так что найти проблему легко.
Q3Как правильно использовать GSAP в React и Next.js?
GSAP работает с DOM, поэтому выполняется только в клиентских компонентах. Я использую хук useGSAP из @gsap/react: он запускается, когда DOM готов, ограничивает строковые селекторы ref-ом контейнера, чтобы .title совпадал только внутри этого компонента, и автоматически откатывает каждый твин и ScrollTrigger при размонтировании компонента. Это важно в Next.js, где клиентская навигация иначе оставляла бы устаревшие триггеры. Анимации, которые создаются позже по событиям, я оборачиваю в contextSafe. А ещё я полностью держу GSAP подальше от серверных компонентов, например помечаю элементы data-атрибутами и даю одному клиентскому провайдеру навешивать эффекты.
Q4Как вы учитываете prefers-reduced-motion?
Я отношусь к этому как к отдельному дизайну движения, а не к выключателю. Через gsap.matchMedia() я регистрирую полные анимации под no-preference и упрощённый набор под reduce: короткие растворения и смену цвета вместо перемещения, без параллакса, без закреплённых скролл-сцен, без плавной прокрутки. matchMedia также автоматически откатывает анимации, если пользователь меняет настройку при открытой странице. В CSS я оборачиваю переходы, которые что-то двигают, в media query no-preference. И независимо от настроек движения контент должен быть полностью видимым и пригодным к использованию, если JavaScript медленный или отключён.
Q5Вредит ли плавная прокрутка доступности или производительности?
Может, если сделана плохо. Старые библиотеки заменяли нативный скролл трансформируемым контейнером, что ломало position: sticky, якоря, поиск по странице и вспомогательные технологии. Lenis сохраняет нативную прокрутку документа и лишь сглаживает ввод, так что всё это продолжает работать. Тем не менее я отключаю его при prefers-reduced-motion, даю вложенным областям скроллиться нативно через data-lenis-prevent и сохраняю прокрутку с клавиатуры. Что касается производительности, сам Lenis лёгкий; реальная цена — то, что выполняется на каждый скролл, поэтому эффекты, привязанные к скроллу, должны анимировать только transform и использовать ScrollTrigger, а не собственные обработчики scroll. На страницах, похожих на приложения или насыщенных формами, я его просто не подключаю.
Q6Как не дать сцене Three.js ухудшить производительность страницы?
Во-первых, загружайте её лениво: импортируйте Three.js динамически, только когда canvas приближается к вьюпорту, чтобы он никогда не задерживал основной контент и LCP. Ограничьте device pixel ratio значением 2, держите количество полигонов и размеры текстур в разумных пределах и используйте сжатые форматы, например текстуры KTX2 и геометрию Draco или Meshopt. Ставьте цикл рендеринга на паузу, когда canvas вне экрана или вкладка скрыта. Делите геометрии и материалы между мешами. При размонтировании освобождайте геометрии, материалы, текстуры и рендерер, чтобы вернуть память GPU. И наконец, предусмотрите статичное изображение как fallback для reduced motion и слабых устройств.
Вопросы уровня senior
S1GSAP, CSS-анимации или Web Animations API: как вы выбираете для каждого эффекта?
Я выбираю по тому, что нужно эффекту. Простые смены состояния вроде hover, focus и открытия или закрытия панелей делаю CSS-переходами: ноль JavaScript, а анимации transform и opacity могут выполняться на композиторе, даже когда главный поток занят. Появления, привязанные к позиции скролла, могут использовать CSS scroll-driven animations там, где это поддерживается, со статическим фолбэком. Web Animations API я беру, когда нужен контроль из JavaScript, например реверс или ожидание завершения, без библиотеки. GSAP оправдывает свой вес на последовательных таймлайнах, скрабируемых и запиненных скролл-сценах, разбиении текста, морфинге SVG и одинаковом поведении в разных браузерах. Его цена — работа в главном потоке на каждый кадр и размер бандла, поэтому плагины я загружаю только там, где они используются. Типичный сайт смешивает все три подхода, а правило одно: самый лёгкий инструмент, который надёжно делает работу.
S2Как вы строите архитектуру анимаций в большом сайте на Next.js, чтобы серверные компоненты оставались без анимаций?
Я отделяю описание от выполнения. Серверные компоненты только рендерят разметку с data-атрибутами, например data-anim='fade-up' или data-anim-pin, плюс своё финальное видимое состояние, так что страница полна и без JavaScript. Один клиентский провайдер, смонтированный в layout, после гидрации ищет эти атрибуты, сопоставляет каждое имя с фабричной функцией в маленьком реестре и создаёт анимации внутри GSAP context, ограниченного страницей. При смене роута он откатывает этот context, что убивает твины и триггеры одним вызовом, и затем сканирует новую страницу. Тяжёлые части вроде Three.js или SplitText импортируются динамически, только если есть элемент, которому они нужны. gsap.matchMedia() оборачивает всё ради reduced motion и брейкпоинтов. Компромисс — немного косвенности, зато бандлы остаются маленькими, серверные компоненты чистыми, а каждый эффект убирается единообразно.
S3Как вы задаёте и соблюдаете бюджет производительности для страниц с большим количеством движения?
Я формулирую бюджеты в терминах пользователя: плавные кадры на среднем Android-телефоне, ни одной длинной задачи дольше 50 мс из-за настройки анимаций, LCP и INP не хуже, чем у страницы без движения, и бюджет JavaScript на код анимаций, например ядро GSAP плюс только используемые плагины. Контроль идёт в три слоя. В разработке я профилирую с замедлением CPU и проверяю кадры и длинные задачи в панели Performance. В CI Lighthouse на ключевых страницах отслеживает Total Blocking Time, а лимиты размера бандла роняют сборку, когда код анимаций растёт. В продакшене библиотека web-vitals отправляет INP с attribution, так что регрессия указывает на виновный скрипт. Когда бюджет превышен, я упрощаю эффект или переношу его в CSS, а не мирюсь с ценой, потому что движение никогда не должно делать страницу медленнее в использовании.
Глоссарий
- Поток композитора
- Поток браузера, который собирает слои в итоговое изображение; он может двигать слои, не дожидаясь JavaScript.
- Повышение до слоя
- Выделение элементу собственного битмапа на GPU, чтобы его можно было дёшево двигать или затемнять ценой памяти.
- Главный поток
- Единственный поток, где выполняются JavaScript, стили, layout и paint; длинные задачи здесь вызывают рывки.
- quickTo
- Хелпер GSAP, возвращающий переиспользуемую функцию для перенацеливания твина одного свойства; идеален для движения за указателем.
- Lag smoothing
- Корректировка GSAP после длинного кадра, чтобы анимации продолжались плавно, а не прыгали вперёд.
- Draw call
- Одна команда от CPU, говорящая GPU нарисовать пакет геометрии с одним материалом.
- InstancedMesh
- Объект Three.js, который рисует много копий одной геометрии и материала за один draw call.
- Компиляция шейдеров
- Превращение GPU-программы материала в машинный код; происходит при первом использовании и может заморозить кадр.
- WebGPU
- Современный браузерный API для графики и вычислений, преемник WebGL, с меньшими накладными расходами и compute-шейдерами.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.