Спроектируй стратегию scroll-triggered анимаций для страницы типа: {{page_type}}. Цель — плавный 60 FPS, INP < 200ms, ноль scroll-jacking.
1. Когда триггерить
Используй пороги viewport visibility, а не пиксельный скролл:
| Элемент | Threshold | Когда |
|---|---|---|
| Hero block (above-fold) | — | Мгновенно при загрузке, не на скролл |
| Section reveal | 0.15-0.25 | Когда 15-25% секции в viewport |
| Stat counter | 0.4-0.5 | Когда секция почти центрирована |
| Image lazy load | rootMargin: "200px" | За 200px до появления |
| Sticky / pin element | 0.1 | Как только верх вошёл |
| Below-fold list stagger | 0.2 | Хотя бы 20% контейнера видно |
Правило: never триггерь на 0.0 (касание края) — анимация запустится раньше, чем пользователь заметит элемент. И never на 1.0 — пропустишь короткие элементы.
2. Intersection Observer паттерн
import { useEffect, useRef, useState } from "react";
function useReveal<T extends HTMLElement>(threshold = 0.2) {
const ref = useRef<T>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
setVisible(true);
io.unobserve(e.target); // one-shot
}
}
},
{ threshold, rootMargin: "0px 0px -10% 0px" }
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, visible };
}
Один Observer на множество элементов — дешевле, чем по одному на каждый. Для секций — общий instance.
3. INP impact
- Каждый scroll event — потенциальный long task. Не вешай heavy callback на
window.scroll. - Используй
requestAnimationFrameдля синхронизации. - Debounce при необходимости — не для reveal (он one-shot), а для непрерывных эффектов (прогресс-бар чтения, sticky header transform).
- INP бюджет: scroll handler ≤ 16ms, в идеале ≤ 8ms.
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateProgressBar();
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
{ passive: true } — обязательно: без него браузер не может оптимизировать scroll-thread.
4. Анимация при reveal
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 500ms ease-out, transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal { transition: opacity 200ms; transform: none; }
}
5. Что НЕ делать
Parallax на длинных страницах
- Background-attachment: fixed → kills mobile Safari FPS, drops to 15-25.
- Translate3d на scroll с тяжёлыми layer'ами (картинки 2000px) → GPU memory pressure.
- Правило: parallax только в hero (выше fold), на короткой дистанции (≤ 100vh).
Scroll-jacking / locked scroll
- Перехват
wheelevents сpreventDefaultдля «cinematic experience» → ломает momentum scroll, ломает accessibility (клавиатурные пользователи), ломает trackpad. - Pin-секции дольше 2 viewport — пользователь думает «зависло». Чем длиннее pin — тем меньше людей доскроллит до конца.
Reveal-on-scroll каждого блока
- Если всё на странице анимируется на скролл — это белый шум. Анимация теряет коммуникативную ценность.
- Лимит: 3-5 reveal-зон на длинной странице.
Анимация на scrollY без rAF
window.addEventListener('scroll', () => el.style.transform = ...)безrequestAnimationFrame— гарантированный jank.
6. Debounce vs throttle
| Случай | Что использовать |
|---|---|
| Reveal (one-shot) | Intersection Observer, без debounce |
| Прогресс-бар чтения | rAF + throttle 16ms |
| Sticky header transform | rAF + throttle 16ms |
| Save scroll position в URL | debounce 300ms |
| Analytics scroll-depth event | debounce 1000ms |
7. Формат вывода
## Scroll motion spec
| Block | Trigger | Threshold | Anim | Reduced |
|---|---|---|---|---|
| Hero | mount | — | opacity 0→1 600ms | fade 200ms |
| Features | IO | 0.2 | stagger reveal | fade |
| Stats | IO | 0.5 | counter 0→N 1s | instant value |
## Reveal hook
[код useReveal]
## QA
- [ ] 60 FPS scroll (DevTools Performance, throttled 4x CPU)
- [ ] INP < 200ms на длинной странице
- [ ] Нет fixed background
- [ ] passive listeners везде
- [ ] Reduced-motion ветка
Анти-паттерны
- ❌
background-attachment: fixedна mobile — гарантированный jank, на iOS вообще игнорируется. - ❌ Перехват wheel/touch для scroll-jacking — ломает accessibility, ломает trackpad inertia.
- ❌
window.scrolllistener без{ passive: true }— браузер не может оптимизировать compositor. - ❌
getBoundingClientRect()в scroll-handler — force layout reflow, jank гарантирован. - ❌ Reveal каждого элемента на странице — белый шум, теряется акцент.
- ❌ Триггер на threshold 0 — анимация играет раньше, чем пользователь видит элемент.
- ❌ Pin-секция длиннее 2 viewport — bounce rate up.
- ❌ Анимация
scrollY → translateYбезrequestAnimationFrame— 30 FPS даже на десктопе. - ❌ Один Intersection Observer на элемент при 100 элементах — память, оверхед. Делай один shared instance.
- ❌ Reveal-animations выше fold — задерживают LCP, ломают perceived performance.
Аудит производительности (Core Web Vitals)
Глубокая проверка LCP, INP, CLS с привязкой к коду и приоритизированным планом исправлений.
Мастер-аудит сайта: 6 измерений за один проход
Orchestrator-аудит по 6 направлениям: UX, accessibility, performance, SEO, brand consistency, security. Quick scan + deep dive + приоритизированный план + композитная оценка + roadmap.
Performance budget по типам страниц
Бюджеты JS/CSS/images для разных типов страниц, целевые Web Vitals, enforcement в CI с конкретными порогами.