Реалізація анімацій при появленні елементів в області видимості (Scroll Reveal)
Scroll reveal — анімації, які запускаються коли елемент потрапляє в видиму область екрану. Базовий інструмент — IntersectionObserver API. Він не потребує scroll-подій, не блокує головний потік, працює асинхронно. Бібліотека AOS або ScrollReveal.js — це обгортки над тим же принципом, але з готовими пресетами.
IntersectionObserver: мінімалістична реалізація
// hooks/useScrollReveal.ts
import { useEffect, useRef } from 'react'
interface ScrollRevealOptions {
threshold?: number // 0–1, доля видимості для срабатування
rootMargin?: string // відступи (як у CSS margin, але для viewport)
once?: boolean // анімувати тільки перший раз
delay?: number // затримка в мс
}
export function useScrollReveal({
threshold = 0.15,
rootMargin = '0px 0px -50px 0px',
once = true,
delay = 0,
}: ScrollRevealOptions = {}) {
const elementRef = useRef<HTMLElement>(null)
useEffect(() => {
const el = elementRef.current
if (!el) return
if (delay > 0) {
el.style.transitionDelay = `${delay}ms`
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('is-visible')
if (once) observer.unobserve(el)
} else if (!once) {
el.classList.remove('is-visible')
}
},
{ threshold, rootMargin }
)
observer.observe(el)
return () => observer.disconnect()
}, [threshold, rootMargin, once, delay])
return elementRef
}
/* styles/scroll-reveal.css */
/* Базове стан — елемент скритий */
.reveal {
opacity: 0;
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.reveal-fade-up {
transform: translateY(40px);
}
.reveal-fade-down {
transform: translateY(-40px);
}
.reveal-fade-left {
transform: translateX(40px);
}
.reveal-fade-right {
transform: translateX(-40px);
}
.reveal-scale {
transform: scale(0.92);
}
/* Видиме стан */
.reveal.is-visible {
opacity: 1;
transform: translate(0) scale(1);
}
/* Швидке усунення анімації для prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
React-компонент Reveal
// components/Reveal.tsx
import { ReactNode, useRef, useEffect, useState } from 'react'
type RevealVariant = 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'scale' | 'fade'
interface RevealProps {
children: ReactNode
variant?: RevealVariant
delay?: number // мс
duration?: number // мс
threshold?: number
once?: boolean
className?: string
as?: keyof JSX.IntrinsicElements
}
export function Reveal({
children,
variant = 'fade-up',
delay = 0,
duration = 600,
threshold = 0.15,
once = true,
className = '',
as: Tag = 'div',
}: RevealProps) {
const ref = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Поважаємо налаштування користувача
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reducedMotion) {
setIsVisible(true)
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
if (once) observer.unobserve(el)
} else if (!once) {
setIsVisible(false)
}
},
{ threshold, rootMargin: '0px 0px -60px 0px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [threshold, once])
const baseStyle: React.CSSProperties = {
transitionDuration: `${duration}ms`,
transitionDelay: isVisible ? `${delay}ms` : '0ms',
transitionProperty: 'opacity, transform',
transitionTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
}
const hiddenStyles: Record<RevealVariant, React.CSSProperties> = {
'fade-up': { opacity: 0, transform: 'translateY(40px)' },
'fade-down': { opacity: 0, transform: 'translateY(-40px)' },
'fade-left': { opacity: 0, transform: 'translateX(40px)' },
'fade-right': { opacity: 0, transform: 'translateX(-40px)' },
'scale': { opacity: 0, transform: 'scale(0.9)' },
'fade': { opacity: 0, transform: 'none' },
}
const style: React.CSSProperties = {
...baseStyle,
...(isVisible ? {} : hiddenStyles[variant]),
}
return (
<Tag ref={ref as any} style={style} className={className}>
{children}
</Tag>
)
}
Використання:
<Reveal variant="fade-up" delay={0}>
<h2>Заголовок</h2>
</Reveal>
<Reveal variant="fade-up" delay={150}>
<p>Текст з затримкою</p>
</Reveal>
<Reveal variant="scale" delay={300}>
<button>Кнопка</button>
</Reveal>
Групповий stagger без затримки в JSX
Коли елементів багато, передавати delay вручну незручно. Група-обертка вирішує це:
// components/RevealGroup.tsx
import { Children, cloneElement, ReactElement } from 'react'
import { Reveal } from './Reveal'
interface RevealGroupProps {
children: ReactNode
staggerMs?: number
variant?: RevealVariant
}
export function RevealGroup({
children,
staggerMs = 100,
variant = 'fade-up',
}: RevealGroupProps) {
return (
<>
{Children.map(children, (child, i) =>
cloneElement(child as ReactElement, {
delay: i * staggerMs,
variant,
})
)}
</>
)
}
<RevealGroup staggerMs={80} variant="fade-up">
<Reveal><div className="card">1</div></Reveal>
<Reveal><div className="card">2</div></Reveal>
<Reveal><div className="card">3</div></Reveal>
</RevealGroup>
Типові строки
Готовий хук + CSS-класи для 3–4 варіантів анімації — 3–4 години. Компонент Reveal з групами, stagger, підтримкою prefers-reduced-motion та TypeScript — 1 робочий день.







