← Назад к вопросам
Как сделать анимацию при прокрутке страницы до определенного места?
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