INTERMEDIATEREACTUI/UX

useIntersectionObserver Hook for React

A performant, TypeScript-safe React hook wrapping IntersectionObserver. Ideal for fade-in-on-scroll, lazy loading, and infinite scroll.

Published Dec 1, 2025Updated Apr 7, 2026
reacthooksintersection-observerlazy-loadingperformancenextjs

useIntersectionObserver is a React hook that wraps the native IntersectionObserver API and gives you a declarative way to track when an element enters or leaves the viewport. Use it for fade-in-on-scroll animations, lazy loading images, infinite scrolling lists, and "section viewed" analytics events.

Unlike listening to scroll and calling getBoundingClientRect on every frame, IntersectionObserver runs off the main thread and fires only when the intersection actually changes, which makes it dramatically cheaper on long pages with many animated elements.

Tested on React 19, TypeScript 5.6, Next.js 16.

When to Use This

  • Fade-in-on-scroll animations: animate cards, images, or sections once as they reach the fold.
  • Lazy loading: defer loading an image, video, or heavy component until it is about to be visible.
  • Infinite scroll: observe a sentinel element at the end of a list and fetch the next page when it becomes visible.
  • Analytics "section viewed" events: fire a tracking event the first time a feature or CTA is actually seen by the user.
  • Sticky navigation highlighting: detect which section is currently in view and highlight the matching sidebar link.

Don't use this when you need a single page-level scroll value. Reach for useScrollProgress instead, because it is cheaper when you only care about overall scroll position, not per-element visibility.

Code

import { useEffect, useRef, useState } from 'react';
 
interface UseIntersectionObserverArgs extends IntersectionObserverInit {
  freezeOnceVisible?: boolean;
}
 
export function useIntersectionObserver(
  elementRef: React.RefObject<Element | null>,
  {
    threshold = 0,
    root = null,
    rootMargin = '0%',
    freezeOnceVisible = false,
  }: UseIntersectionObserverArgs = {}
): IntersectionObserverEntry | undefined {
  const [entry, setEntry] = useState<IntersectionObserverEntry>();
 
  const frozen = entry?.isIntersecting && freezeOnceVisible;
 
  useEffect(() => {
    const node = elementRef?.current;
    const hasIOSupport = !!window.IntersectionObserver;
 
    if (!hasIOSupport || frozen || !node) return;
 
    const observerParams = { threshold, root, rootMargin };
 
    const observer = new IntersectionObserver(([newEntry]) => {
      setEntry(newEntry);
    }, observerParams);
 
    observer.observe(node);
 
    return () => observer.disconnect();
  }, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]);
 
  return entry;
}

The JSON.stringify(threshold) in the dependency array is intentional. Passing a fresh array literal like [0, 0.25, 0.5] on every render would otherwise tear down and rebuild the observer on every parent re-render, which defeats the whole point. Stringifying gives a stable key that only changes when the threshold values actually change.

Usage

Here is how to build a FadeInSection component that fades its children in once as they enter the viewport:

import { useRef } from 'react';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
 
export function FadeInSection({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const entry = useIntersectionObserver(ref, {
    freezeOnceVisible: true, // Only fade in once
    threshold: 0.1, // Trigger when 10% visible
  });
 
  const isVisible = !!entry?.isIntersecting;
 
  return (
    <div
      ref={ref}
      className={`transition-opacity duration-1000 ${
        isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
      }`}
    >
      {children}
    </div>
  );
}

freezeOnceVisible: true tells the hook to stop observing after the element has been seen, which is exactly what you want for one-off entrance animations. Drop it for elements that should react every time they enter or leave the viewport.

Things to Know

  • freezeOnceVisible is not a guarantee. It stops the effect from re-subscribing after the element has been seen. If the same element later moves out and back into view, no new entries fire, which is the whole point for one-off animations but can surprise you if you reuse the hook for sticky headers.
  • Refs can be null on the first render. The hook guards !node so you can safely pass a ref that has not attached yet, which is common during the first render in React 19 and the Next.js App Router.
  • Feature detection. IntersectionObserver is checked before use. If the browser somehow lacks it, the hook becomes a no-op instead of crashing, which matters if you support older WebViews.
  • One observer per element. This hook is deliberately one observer per element for clarity. If you are animating hundreds of cards in a heavy list, hoist a single parent observer and dispatch intersection events yourself, since that is cheaper at scale.
  • Threshold stability. Always use a useMemo or a module-level constant for complex threshold arrays if you are not passing a primitive, even with the stringify guard in place.
  • useScrollProgress Hook: the right tool for page-level scroll tracking instead of per-element visibility.
  • useDebounce Hook: pair with useIntersectionObserver to throttle analytics events from a fast-scrolling list.
  • Streaming Suspense Loading in Next.js(coming soon): another pattern for making long pages feel fast on first render.
X (Twitter)LinkedIn