Intersection Observer in React: Scroll-Triggered Animations the Right Way
Learn how to wire up Intersection Observer in React for scroll-triggered animations without killing performance — custom hooks, cleanup, and real code included.
Why Scroll Events Are the Wrong Tool
You've probably done it — attached a scroll event listener, calculated getBoundingClientRect() in a handler, and watched Chrome DevTools flag the whole thing as a jank source. Scroll events fire dozens of times per second, they run on the main thread, and they don't batch. For anything beyond the most trivial fade-in, they're the wrong choice in 2026.
Intersection Observer was designed specifically for this. It runs off the main thread, fires only when visibility thresholds cross, and costs you basically nothing at idle. The API's been in every major browser since 2018 — there's no polyfill conversation to have anymore.
In practice, the main reason people still reach for scroll events is familiarity. The Observer API looks a bit alien the first time. That's the gap this article closes.
How Intersection Observer Actually Works
The core idea: you register a callback and a list of elements. The browser calls your callback whenever any of those elements cross a visibility threshold — say, 10% visible, or 50%, or fully in view. You don't poll. You don't calculate. You just respond.
The IntersectionObserverEntry your callback receives has everything you need: isIntersecting (boolean), intersectionRatio (0.0–1.0), boundingClientRect, and target. Most animations only need isIntersecting and target.
Worth noting: the rootMargin option works exactly like CSS margin — you can fire the callback 100px before an element enters the viewport by setting rootMargin: '100px 0px'. That head-start is how you get buttery pre-loaded animations that feel instant to the user.
One more thing — the observer pattern is *reusable*. One IntersectionObserver instance can watch hundreds of elements simultaneously. You're not creating one per element.
A Custom useIntersectionObserver Hook
Here's the hook you'll actually reach for. It wraps the full lifecycle — create, observe, disconnect — and returns both a ref and the entry object so you keep full control in the component.
import { useEffect, useRef, useState } from 'react';
export function useIntersectionObserver(options = {}) {
const ref = useRef(null);
const [entry, setEntry] = useState(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{
threshold: options.threshold ?? 0.1,
rootMargin: options.rootMargin ?? '0px',
root: options.root ?? null,
}
);
observer.observe(el);
return () => observer.disconnect();
}, [options.threshold, options.rootMargin, options.root]);
return { ref, entry, isVisible: !!entry?.isIntersecting };
}The cleanup is non-negotiable. If you skip observer.disconnect(), you'll leak observers on every remount — noticeable in development with React 18's strict double-invoke, brutal in production on SPAs where users never hard-reload.
Honestly, the dependency array here is worth a second look. Passing the whole options object as a dep would re-run the effect on every render since objects don't have stable identity. Threading out the primitive values keeps it stable without you needing useMemo at the call site.
Wiring Animations: CSS Classes vs Inline Styles
Once you have the hook, there are two paths: toggle a CSS class or drive inline styles directly. CSS classes win almost every time. You get GPU-composited transforms, will-change hints, and the full power of cubic-bezier curves — all without touching JavaScript on every frame.
import { useIntersectionObserver } from './useIntersectionObserver';
import './FadeUp.css';
export function FadeUp({ children, delay = 0 }) {
const { ref, isVisible } = useIntersectionObserver({ threshold: 0.15 });
return (
<div
ref={ref}
className={`fade-up ${isVisible ? 'fade-up--visible' : ''}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}/* FadeUp.css */
.fade-up {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.5s ease, transform 0.5s ease;
will-change: opacity, transform;
}
.fade-up--visible {
opacity: 1;
transform: translateY(0);
}That translateY(24px) offset is intentional. Values below 30px feel grounded — anything larger and the animation reads as content jumping rather than revealing. The delay prop lets you stagger sibling cards by passing multiples of 100ms, which you can do without any animation library at all.
If you're building something more theatrical — parallax, clip-path reveals, counter animations — you might want inline styles driven by intersectionRatio. But for 90% of scroll animation work, the class-toggle pattern above is all you need. Browse the Empire UI component library for real examples of this pattern applied to cards, hero sections, and stat counters.
Handling the 'Already Visible on Mount' Problem
Here's a subtle bug that bites you in production but not always in dev: if an element is already in the viewport on initial render — say, a hero section — the observer fires immediately. That's correct behavior, but your animation might flash or skip if the CSS transition hasn't been applied yet by the time the class toggles.
The fix is a one-tick delay via requestAnimationFrame or just letting the CSS transition duration handle it naturally. A 50ms transition-delay on .fade-up (not .fade-up--visible) gives the browser just enough time to paint the initial invisible state before the class lands.
Quick aside: this is also why you should always initialize your element in the *pre-animation* state (opacity 0, translated down) rather than trying to add it on mount. The observer firing on mount is not a bug — it's the feature working.
If you're server-side rendering with Next.js, remember that IntersectionObserver doesn't exist in Node. Guard your hook: if (typeof window === 'undefined') return; before creating the instance, or use useEffect which already only runs client-side.
Performance Tips You'll Actually Use
The threshold array form is underused. You can pass [0, 0.25, 0.5, 0.75, 1] to get callbacks at each quartile — useful for progress bars or sticky nav state. Single-value thresholds are fine for simple show/hide; richer animations want multiple triggers.
Unobserve after the first intersection if your animation only fires once. Re-observing a visible element that's already animated is wasted work. Inside your callback: observer.unobserve(entry.target). The hook above keeps observing by default, which you'd want for elements that animate in *and* out. Pick based on your use case.
For pages with 50+ animated elements — think a long landing page with card grids — consider grouping elements under a single observer instance rather than mounting 50 hook instances. The hook pattern is convenient but creates one observer per component. A context-based observer that accepts refs scales better at that volume. That said, for most apps the per-component approach is fine and the overhead is negligible.
Pair your scroll animations with the right CSS properties. opacity and transform are composited by the browser and never trigger layout. width, height, top, left — those trigger layout recalc and will cause jank regardless of how elegantly you've set up the observer. If you want animated designs that already have this figured out, the glassmorphism components on Empire UI are built on transform-only animations throughout.
When to Skip Intersection Observer
Not everything needs it. If an animation plays once on page load and the element is always above the fold, a CSS @keyframes with no JavaScript at all is simpler and faster. Save the Observer for things that genuinely depend on scroll position.
Also worth thinking about: prefers-reduced-motion. Users with vestibular disorders or motion sensitivity have explicitly asked the browser not to animate. Respect that.
@media (prefers-reduced-motion: reduce) {
.fade-up {
opacity: 1;
transform: none;
transition: none;
}
}Two lines of CSS, and you've made your animations accessible. Don't ship scroll animations without this. You can also read the media query in JS via window.matchMedia('(prefers-reduced-motion: reduce)').matches and skip adding the class entirely if you want tighter control.
For production-grade UI that already handles all of this — accessible animations, consistent tokens, composited transforms — browse the components and save yourself the ground-up build time.
FAQ
Yes — full support across Chrome, Firefox, Safari, and Edge since 2018. You don't need a polyfill for any modern browser target. If you're supporting IE11 for some reason, stop and have a conversation with your stakeholders.
Absolutely. The observer lives outside React's render cycle, so concurrent mode doesn't affect it. Just make sure your state updates inside the callback are wrapped normally — React 18 batches them automatically in most cases.
threshold controls how much of the element must be visible (0.0 to 1.0) before the callback fires. rootMargin expands or shrinks the viewport boundary — use it to trigger animations before the element technically enters view.
The react-intersection-observer package is well-maintained and saves you the hook boilerplate, which is genuinely useful on larger projects. For simple use cases, the custom hook in this article is maybe 20 lines — it's not worth a dependency unless you need the extras.