← Назад к вопросам

Как сделать анимацию при прокрутке страницы до определенного места?

2.0 Middle🔥 151 комментариев
#JavaScript Core

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как сделать анимацию при прокрутке страницы до определенного места?

Intersection Observer API (современный подход)

Это лучший способ отслеживать, когда элемент входит в видимую область. Он эффективен и не требует постоянного слушания scroll события.

// === Базовый пример: анимация при видимости ===

import { useEffect, useRef } from 'react';

export function AnimateOnScroll() {
  const elementRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            // Элемент вошёл в видимую область
            entry.target.classList.add('animate-in');
            // Опционально: отписаться после первого срабатывания
            observer.unobserve(entry.target);
          }
        });
      },
      {
        threshold: 0.1, // срабатывает, когда 10% элемента видно
      }
    );

    if (elementRef.current) {
      observer.observe(elementRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div
      ref={elementRef}
      className="fade-in" // style будет применён через .animate-in
    >
      Content that animates on scroll
    </div>
  );
}

// === CSS для анимации ===
/* Стиль по умолчанию */
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

/* Стиль после добавления класса .animate-in */
.fade-in.animate-in {
  opacity: 1;
  transform: translateY(0);
}

Кастомный хук для переиспользования

// === hooks/useIntersectionObserver.ts ===

import { useEffect, useRef } from 'react';

interface UseIntersectionObserverOptions {
  threshold?: number | number[];
  rootMargin?: string;
  triggerOnce?: boolean; // если true, срабатывает только один раз
}

export function useIntersectionObserver(
  options: UseIntersectionObserverOptions = {}
) {
  const elementRef = useRef<HTMLDivElement>(null);
  const { threshold = 0.1, rootMargin = '0px', triggerOnce = true } = options;

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            entry.target.classList.add('is-visible');
            if (triggerOnce) {
              observer.unobserve(entry.target);
            }
          } else if (!triggerOnce) {
            entry.target.classList.remove('is-visible');
          }
        });
      },
      { threshold, rootMargin }
    );

    if (elementRef.current) {
      observer.observe(elementRef.current);
    }

    return () => observer.disconnect();
  }, [threshold, rootMargin, triggerOnce]);

  return elementRef;
}

// === Использование хука ===

export function ScrollAnimatedCard() {
  const ref = useIntersectionObserver({ threshold: 0.2, triggerOnce: true });

  return (
    <div
      ref={ref}
      className="card transition duration-500 opacity-0
                 is-visible:opacity-100"
    >
      Card that animates when scrolled into view
    </div>
  );
}

Tailwind CSS для анимаций

// === Встроенные Tailwind анимации ===

export function AnimatedElements() {
  const ref1 = useIntersectionObserver({ threshold: 0.1 });
  const ref2 = useIntersectionObserver({ threshold: 0.1 });
  const ref3 = useIntersectionObserver({ threshold: 0.1 });

  return (
    <>
      {/* Fade In анимация */}
      <div
        ref={ref1}
        className="opacity-0 transition duration-700
                   is-visible:opacity-100"
      >
        Fade In
      </div>

      {/* Slide Up анимация */}
      <div
        ref={ref2}
        className="translate-y-10 opacity-0 transition duration-700
                   is-visible:translate-y-0 is-visible:opacity-100"
      >
        Slide Up
      </div>

      {/* Scale анимация */}
      <div
        ref={ref3}
        className="scale-95 opacity-0 transition duration-700
                   is-visible:scale-100 is-visible:opacity-100"
      >
        Scale In
      </div>
    </>
  );
}

// === Кастомная анимация в globals.css ===

@keyframes slideInLeft {
  from {
    transform: translateX(-100px);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.animate-slide-in-left {
  animation: slideInLeft 0.6s ease forwards;
}

Прослушивание скролла вручную (старый способ)

// === НЕ РЕКОМЕНДУЕТСЯ: использование scroll события ===
// (неэффективно, может вызвать jank)

export function ScrollListenerExample() {
  const elementRef = useRef(null);

  useEffect(() => {
    function handleScroll() {
      if (!elementRef.current) return;

      const elementRect = elementRef.current.getBoundingClientRect();
      const isVisible = elementRect.top < window.innerHeight;

      if (isVisible) {
        elementRef.current.classList.add('animate-in');
      }
    }

    // Слушаем scroll событие (неэффективно!)
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <div ref={elementRef}>Content</div>;
}

// Почему плохо:
// - scroll событие срабатывает много раз в секунду
// - getBoundingClientRect() вызывает reflow
// - приводит к performance issues

Parallax эффект

// === Parallax скроллинг ===

export function ParallaxSection() {
  const ref = useRef<HTMLDivElement>(null);
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    function handleScroll() {
      setOffset(window.scrollY * 0.5); // 0.5 = скорость parallax
    }

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div
      ref={ref}
      style={{
        transform: `translateY(${offset}px)`,
        transition: 'transform 0s', // без transition для плавного движения
      }}
      className="bg-cover bg-center h-96"
    >
      Parallax Background
    </div>
  );
}

// === Более оптимизированный parallax с requestAnimationFrame ===

export function OptimizedParallax() {
  const ref = useRef<HTMLDivElement>(null);
  const [offset, setOffset] = useState(0);
  const animationFrameRef = useRef<number>();

  useEffect(() => {
    function handleScroll() {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }

      animationFrameRef.current = requestAnimationFrame(() => {
        setOffset(window.scrollY * 0.5);
      });
    }

    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, []);

  return (
    <div
      ref={ref}
      style={{ transform: `translateY(${offset}px)` }}
      className="bg-cover h-96"
    />
  );
}

Продвинутый пример: счётчик при видимости

// === Анимированный счётчик ===

export function CounterOnScroll({ targetNumber = 100 }) {
  const ref = useRef<HTMLDivElement>(null);
  const [count, setCount] = useState(0);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && count === 0) {
            // Начинаем анимацию счёта
            const duration = 2000; // 2 секунды
            const steps = 60; // 60 fps
            const increment = targetNumber / steps;
            let current = 0;

            const interval = setInterval(() => {
              current += increment;
              if (current >= targetNumber) {
                setCount(targetNumber);
                clearInterval(interval);
              } else {
                setCount(Math.floor(current));
              }
            }, duration / steps);
          }
        });
      },
      { threshold: 0.5 }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, [targetNumber, count]);

  return (
    <div ref={ref} className="text-center">
      <p className="text-4xl font-bold">{count}</p>
      <p className="text-gray-600">Projects Completed</p>
    </div>
  );
}

Best Practices

// OK: Используй Intersection Observer
const observer = new IntersectionObserver(callback, { threshold: 0.1 });

// OK: Используй requestAnimationFrame для плавных анимаций
requestAnimationFrame(() => {
  element.style.transform = `translateY(${offset}px)`;
});

// NOT OK: Не слушай scroll постоянно
window.addEventListener('scroll', () => {
  // Дорого! Срабатывает 60+ раз в секунду
});

// OK: Используй CSS transitions для лучшей производительности
element.style.transition = 'opacity 0.6s ease';
element.classList.add('visible');

// NOT OK: Не меняй много свойств сразу при скролле
// Вызывает reflow и репaint