INTERMEDIATEREACTUI/UX

useIntersectionObserver Hook

A performant React hook to track when an element enters or leaves the viewport. Perfect for scroll animations and lazy loading.

Published Dec 2, 2025
reacthooksanimationperformance

useIntersectionObserver Hook

This hook wraps the native IntersectionObserver API to provide a declarative way to track element visibility. It's essential for implementing "fade-in on scroll" effects, lazy loading images, or infinite scrolling lists.

Highlights

  • Performance First: Uses a single observer instance (per unique options) or cleans up efficiently.
  • Freeze Once Visible: Optional freezeOnceVisible flag to stop observing after the element enters the viewport (great for one-off animations).
  • Type Safe: Fully typed with TypeScript.

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;
}

Usage

Here is how you can use it to create a "Fade In" animation component:

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>
  );
}

๐Ÿ’ก Pro Tip: For complex lists with hundreds of items, consider using a single parent observer instead of creating a hook for every item to save memory.