Skip to content
PПромтбук
RUEN
02Motion

Scroll-triggered анимации без боли

Когда триггерить (% viewport), Intersection Observer pattern, INP impact, что НЕ делать — parallax длинных страниц, locked scroll.

Спроектируй стратегию scroll-triggered анимаций для страницы типа: {{page_type}}. Цель — плавный 60 FPS, INP < 200ms, ноль scroll-jacking.

1. Когда триггерить

Используй пороги viewport visibility, а не пиксельный скролл:

ЭлементThresholdКогда
Hero block (above-fold)Мгновенно при загрузке, не на скролл
Section reveal0.15-0.25Когда 15-25% секции в viewport
Stat counter0.4-0.5Когда секция почти центрирована
Image lazy loadrootMargin: "200px"За 200px до появления
Sticky / pin element0.1Как только верх вошёл
Below-fold list stagger0.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

  • Перехват wheel events с 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 transformrAF + throttle 16ms
Save scroll position в URLdebounce 300ms
Analytics scroll-depth eventdebounce 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.scroll listener без { 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.
К подразделу «Motion»
Похожие промты