Scroll Reveal Patterns
Scroll reveal is an animation triggered when an element enters the viewport. Done right: it makes the page feel “alive”, with content appearing right as the reader looks at it. Done wrong: the user scrolls down and sees… emptiness, has to wait for the animation, and loses the content.
Scroll inside the box to watch each item fade up into view
Intersection Observer
The foundation of scroll reveal is IntersectionObserver — a native API that tracks when an element intersects the viewport.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
entry.target.classList.add('revealed');
observer.unobserve(entry.target); // animate only once
});
},
{ threshold: 0.15 } // trigger when 15% of the element is visible
);
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.reveal.revealed {
opacity: 1;
transform: translateY(0);
}
Why unobserve after triggering: scroll reveal should run only once. If the animation resets every time the user scrolls up and down, it’s distracting and unnatural.
Pattern 1: Simple fade-up
Suitable for most content blocks (heading, paragraph, image).
.fade-up {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.fade-up.revealed {
opacity: 1;
transform: translateY(0);
}
Translate only needs 16–24px to create a directional cue. No need for 60px or 100px — that would look like the element “falling out of the sky”.
Pattern 2: Stagger group
Multiple elements in a container appear one after another. See the details in Stagger Animation.
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const items = entry.target.querySelectorAll('.stagger-item');
items.forEach((el, i) => {
el.style.transitionDelay = `${i * 60}ms`;
el.classList.add('revealed');
});
observer.unobserve(entry.target);
});
}, { threshold: 0.1 });
Observe the container, not each item: if you observe each item individually, items that aren’t visible will trigger randomly as you scroll. The container is the semantic unit.
Pattern 3: Scroll-linked (parallax)
Elements move at different speeds based on scroll position. Unlike reveal — this is a continuous animation.
// Scroll-linked with a CSS custom property
const section = document.querySelector('.parallax-section');
const img = section.querySelector('.parallax-img');
window.addEventListener('scroll', () => {
const rect = section.getBoundingClientRect();
const progress = 1 - (rect.bottom / (rect.height + window.innerHeight));
img.style.setProperty('--parallax-y', `${progress * 40}px`);
}, { passive: true });
.parallax-img {
transform: translateY(var(--parallax-y, 0));
will-change: transform;
}
Use { passive: true } for the scroll listener so it doesn’t block rendering. And will-change: transform to get a GPU composite layer.
Use sparingly: parallax looks nice but costs performance. On mobile, consider disabling it entirely.
Threshold and rootMargin
threshold is the fraction of the element that must be visible to trigger (0 = 1 pixel, 1 = the entire element).
rootMargin expands or shrinks the trigger area (measured from the edge of the viewport).
// Trigger 100px earlier, before the element enters the viewport
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px -100px 0px', // negative bottom margin
threshold: 0
});
rootMargin: '0px 0px -100px 0px' means it triggers when the element is still 100px from the bottom of the viewport — enough time for the animation to start before the user actually looks at it.
Common mistakes
Above-the-fold content is hidden: content that’s visible immediately on load isn’t revealed right away — the user sees a blank page before the animation runs. Check: all content above the fold must be visible immediately.
// Solution: only observe elements below the fold
const foldLine = window.innerHeight;
document.querySelectorAll('.reveal').forEach(el => {
if (el.getBoundingClientRect().top < foldLine) {
el.classList.add('revealed'); // show immediately
} else {
observer.observe(el);
}
});
Too many elements reveal at once: if 20 elements all trigger at the same threshold, they appear almost simultaneously — there’s no cascade. Increase the threshold or use stagger.
Not handling prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
}
See also: Stagger Animation, Cubic Bezier In Depth.