Implementing Scroll Reveal Animations on a Website
Scroll reveal are animations triggered when an element enters the visible area. The basic tool is the IntersectionObserver API. It doesn't require scroll events, doesn't block the main thread, works asynchronously. AOS or ScrollReveal.js libraries are wrappers around the same principle but with ready presets.
IntersectionObserver: Minimalist Implementation
// hooks/useScrollReveal.ts
import { useEffect, useRef } from 'react'
interface ScrollRevealOptions {
threshold?: number // 0–1, fraction of visibility
rootMargin?: string // offsets (like CSS margin but for viewport)
once?: boolean // animate only first time
delay?: number // delay in ms
}
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 */
/* Base state — element hidden */
.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);
}
/* Visible state */
.reveal.is-visible {
opacity: 1;
transform: translate(0) scale(1);
}
/* Fast animation removal for prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
React Reveal Component
// 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 // ms
duration?: number // ms
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
// Respect user settings
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>
)
}
Usage:
<Reveal variant="fade-up" delay={0}>
<h2>Heading</h2>
</Reveal>
<Reveal variant="fade-up" delay={150}>
<p>Text with delay</p>
</Reveal>
<Reveal variant="scale" delay={300}>
<button>Button</button>
</Reveal>
Group Stagger without Delay in JSX
When there are many elements, passing delay manually is inconvenient. A group-wrapper solves this:
// 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>
Typical Timeline
Ready hook + CSS classes for 3–4 animation variants — 3–4 hours. Reveal component with groups, stagger, prefers-reduced-motion support and TypeScript — 1 working day.







