A good scroll animation makes elements appear at exactly the right moment as you move down the page — never too early, never too late. For years we built this by listening to the scroll event and calculating every frame by hand. The browser can now do this for us without straining the main thread: IntersectionObserver. In this guide I walk through how to efficiently detect when an element enters the viewport, how to pair that with CSS for smooth transitions, and how to avoid the usual performance and accessibility traps.
Why Intersection Observer instead of the scroll event?
The classic approach used window.addEventListener('scroll', ...) and called getBoundingClientRect() for every element on every scroll step. Two problems: the scroll event fires dozens of times per second, and getBoundingClientRect() forces the browser to recalculate layout (reflow). Together they clog the main thread and make scrolling stutter.
Intersection Observer works the other way around: you tell the browser "let me know when this element comes into view," and the browser tracks it internally, asynchronously and optimized. The callback only runs when the intersection state changes, not on every scroll frame. The result: less code, less reflow, a smoother page.
Setting up your first observer
Basic usage has three parts: the target elements to observe, a callback that runs on intersection, and options that tune the behavior.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
// stop watching once it has appeared
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.2, // fire when 20% of the element is visible
rootMargin: '0px 0px -10% 0px'
});
document.querySelectorAll('.reveal').forEach((el) => {
observer.observe(el);
});
The key trick here: JavaScript only adds a CSS class (is-visible). CSS does the actual animation. That separation is critical for both performance and maintainability.
Leaving the animation to CSS
We define the element's start and end states in CSS and smooth the transition between them with transition. Using transform and opacity matters: these two properties are handled on the GPU and do not trigger a layout recalculation.
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
With the same structure you can produce variations like sliding in from the left, scaling, or a slight rotation using different classes. Another advantage of keeping animation in CSS: even if JavaScript fails to run, the content shouldn't stay hidden at opacity: 0 — we'll secure that in the accessibility section below.
Tuning threshold and rootMargin correctly
These two options entirely define how a scroll animation feels:
- threshold: a ratio between 0 and 1.
0fires when a single pixel is visible,1when the whole element is. You can also pass an array ([0, 0.25, 0.5, 1]) and receive the callback at each threshold. - rootMargin: grows or shrinks the root box; it uses CSS
marginsyntax. A negative bottom value like-10%delays the trigger until the element is a bit further into the screen, so the animation feels "just in time." - root: defaults to the viewport (
null). If you observe inside a scrollable container, pass that element asroot.
Staggered entrance effect
Cards appearing one after another with a slight delay is a favorite in modern interfaces. You can do this cleanly by assigning a delay to each element via a CSS variable:
const items = document.querySelectorAll('.reveal');
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const i = Number(entry.target.dataset.index || 0);
entry.target.style.setProperty('--delay', `${i * 80}ms`);
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
});
}, { threshold: 0.15 });
items.forEach((el, i) => {
el.dataset.index = i;
io.observe(el);
});
On the CSS side you bind that delay to the transition: transition-delay: var(--delay, 0ms);. Four cards then appear in sequence, in a smooth wave.
Performance and accessibility traps
A few details make the project feel professional:
- Manage re-triggering: once an element has appeared, stop watching it with
unobserve(); otherwise the animation resets when the user scrolls back up. If you do want it to replay, remove theis-visibleclass when!entry.isIntersecting. - Reduced-motion preference: some users are bothered by motion. Use
@media (prefers-reduced-motion: reduce)to disable transitions and show content directly. - Don't keep content hidden if JavaScript is off: apply the initial hidden state only when JS is active. A practical approach: add
document.documentElement.classList.add('js')to the<html>element and condition the CSS as.js .reveal { opacity: 0; }.
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
Frequently Asked Questions
How is browser support for Intersection Observer?
It has been supported in all current browsers (Chrome, Firefox, Safari, Edge) for years; no separate library needed. Unless you target very old browsers, you won't need a polyfill either.
Is listening to scroll with requestAnimationFrame better?
For continuous effects that change throughout the scroll, like parallax, requestAnimationFrame is appropriate. But for knowing whether an element is visible, Intersection Observer is always the more efficient and simpler solution.
Should the animation replay on every scroll?
Usually no. Let content appear once and stay; constantly repeating entrance animations are distracting and tax performance. In rare cases you can replay, but unobserve() is the recommended default.
Want smooth, performant scroll animations on your site? For accessible, jank-free interface effects tuned with the right thresholds, get in touch with me.