For scroll-triggered animations, the Intersection Observer API is still the most reliable choice in 2026. It avoids per-frame layout reads, the code is short, and it works in every modern browser.
CSS-only scroll-driven animations (animation-timeline: view()) now exist too, but Firefox still ships them behind a flag, so as of September 2026 the practical setup is: Intersection Observer as the baseline, CSS scroll-driven animation as progressive enhancement.
This article walks through a working fade-in, and covers the traps that actually bite: callback declaration order, where to put transition, and what happens when JavaScript fails.
Sponsored
What the Intersection Observer API does
The Intersection Observer API lets the browser watch how much a target element overlaps the viewport (or any ancestor) and calls your callback only when a threshold is crossed.
The key point is that the check is decoupled from the main-thread scroll event. Calling getBoundingClientRect() inside a scroll handler forces layout recalculation on every frame, which is where jank comes from. Intersection Observer hands that work to the browser, so scrolling stays smooth while you still get “fire when visible”.
Main methods
new IntersectionObserver(callback, options)— create the observerobserve(element)— start watching an elementunobserve(element)— stop watching that one elementdisconnect()— stop watching everythingtakeRecords()— read pending records synchronously (rarely needed)
The two options that matter
| Option | Meaning | Common values |
|---|---|---|
threshold |
How much of the element must be visible | 0 (any pixel) / 0.5 (half) |
rootMargin |
Grow or shrink the detection box | "0px 0px -20% 0px" (fire later) |
root |
Ancestor used as the reference | null (the viewport) |
In practice you will only ever tune threshold and rootMargin.
Minimum working fade-in
Here is the code first. Declare the callback before creating the IntersectionObserver. Reversing the order throws ReferenceError: Cannot access 'callback' before initialization because of the temporal dead zone for const.
HTML
<div class="fade-in-element">
Content revealed on scroll
</div>
CSS
Put transition on the initial-state class. If you only put it on the end-state class, the animation will not play when the element reverses.
.fade-in-element {
opacity: 0;
transform: translateY(24px);
transition: opacity .6s ease-out, transform .6s ease-out;
}
.fade-in-element.is-visible {
opacity: 1;
transform: none;
}
JavaScript
const callback = (entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // reveal once, then stop watching
});
};
const observer = new IntersectionObserver(callback, {
threshold: 0.2,
rootMargin: '0px 0px -10% 0px',
});
document
.querySelectorAll('.fade-in-element')
.forEach((el) => observer.observe(el));
Note that a single observer instance handles every element. You never need one observer per element.
Sponsored
Do not let content disappear when JavaScript fails
The biggest trap in this pattern: if JavaScript never runs, your content stays at opacity: 0 and becomes unreadable. A failed CDN request or a single script error is enough.
The fix is to apply the hiding class from JavaScript, not from static CSS.
/* only hide when JS is alive */
.js-anim .fade-in-element {
opacity: 0;
transform: translateY(24px);
transition: opacity .6s ease-out, transform .6s ease-out;
}
.js-anim .fade-in-element.is-visible {
opacity: 1;
transform: none;
}
document.documentElement.classList.add('js-anim');
Now a total JavaScript failure degrades to “content visible, no animation” instead of a blank page. This matters for both SEO and accessibility.
Is prefers-reduced-motion support required?
Yes. Users who enable “reduce motion” at the OS level may experience nausea or discomfort from motion. One CSS block covers it.
@media (prefers-reduced-motion: reduce) {
.js-anim .fade-in-element {
opacity: 1;
transform: none;
transition: none;
}
}
You can also check it in JavaScript and skip observing altogether.
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
document
.querySelectorAll('.fade-in-element')
.forEach((el) => observer.observe(el));
}
Sponsored
Can CSS scroll-driven animations replace this?
Not entirely, not yet. animation-timeline: view() shipped in Chrome 115 and Edge 115, and Safari added it in version 26, but Firefox still keeps it behind the layout.css.scroll-driven-animations.enabled flag. MDN does not list it as Baseline.
So the correct production answer is progressive enhancement: keep Intersection Observer, and hand the work to CSS only where it is supported, via @supports.
@supports (animation-timeline: view()) {
.js-anim .fade-in-element {
opacity: 1;
transform: none;
transition: none;
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}
Where CSS handles it, the animation tracks scroll position exactly (it rewinds when you scroll back) and barely touches the main thread. Everywhere else, Intersection Observer keeps working.
| Approach | Support | Best for |
|---|---|---|
| Intersection Observer | All modern browsers | One-shot reveals, adding elements, measurement |
animation-timeline: view() |
Chrome / Edge / Safari 26. Firefox flagged | Animation that tracks scroll continuously |
scroll event |
Everywhere | Almost no reason to pick it today |
Three performance rules
The observer itself is cheap. What you animate is what decides how it feels.
- Animate only
opacityandtransform— animatingtoporheightforces layout and causes jank - Call
unobserve()after the reveal — there is no reason to keep watching a one-shot animation - Reuse one observer — a single instance is fine for 100 elements
Use observer.disconnect() to release everything at once. In an SPA, call it when the view unmounts.
For related uses of the same API, see how to build infinite scroll with the Intersection Observer API and how to optimise lazy loading. If all you need is lazy image loading, native HTML lazy loading is simpler.
Summary
- Intersection Observer is the 2026 default for scroll animation; the
scrollevent is no longer worth choosing - Declare the callback before the observer, or the temporal dead zone throws
- Put
transitionon the initial-state class, and animate onlyopacityandtransform - Add the hiding class from JavaScript so content never disappears on script failure
- Always handle
prefers-reduced-motion: reduce - CSS
animation-timeline: view()is not enabled by default in Firefox — layer it on with@supports
The minimum version is about twenty lines. Paste it, then tune threshold and rootMargin to your layout.