React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

intersection-observerlazy-loadinginfinite-scrollviewportanimationcustom-hook

useIntersectionObserver — Observe when elements enter or exit the viewport

Recipe

import { useState, useEffect, useRef, useCallback } from "react";
 
interface UseIntersectionObserverOptions {
  /** Element that is used as the viewport. Default: browser viewport */
  root?: Element | null;
  /** Margin around the root. Default: "0px" */
  rootMargin?: string;
  /** Percentage of element visible to trigger. 0-1 or array. Default: 0 */
  threshold?: number | number[];
  /** Only trigger once (e.g., for lazy loading). Default: false */
  triggerOnce?: boolean;
  /** Start observing immediately. Default: true */
  enabled?: boolean;
}
 
interface UseIntersectionObserverReturn {
  /** Ref to attach to the target element */
  ref: (node: Element | null) => void;
  /** The latest IntersectionObserverEntry */
  entry: IntersectionObserverEntry | null;
  /** Whether the element is currently intersecting */
  isIntersecting: boolean;
}
 
function useIntersectionObserver(
  options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
  const {
    root = null,
    rootMargin = "0px",
    threshold = 0,
    triggerOnce = false,
    enabled = true,
  } = options;
 
  const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);
  const nodeRef = useRef<Element | null>(null);
  const frozenRef = useRef(false);
 
  const cleanup = useCallback(() => {
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = null;
    }
  }, []);
 
  // Callback ref pattern for flexible element targeting
  const ref = useCallback(
    (node: Element | null) => {
      // Cleanup previous observer
      cleanup();
      nodeRef.current = node;
 
      if (!node || !enabled || frozenRef.current) return;
 
      if (typeof IntersectionObserver === "undefined") return;
 
      observerRef.current = new IntersectionObserver(
        ([observedEntry]) => {
          setEntry(observedEntry);
 
          if (triggerOnce && observedEntry.isIntersecting) {
            frozenRef.current = true;
            cleanup();
          }
        },
        { root, rootMargin, threshold }
      );
 
      observerRef.current.observe(node);
    },
    [root, rootMargin, threshold, triggerOnce, enabled, cleanup]
  );
 
  // Cleanup on unmount
  useEffect(() => {
    return cleanup;
  }, [cleanup]);
 
  return {
    ref,
    entry,
    isIntersecting: entry?.isIntersecting ?? false,
  };
}

When to reach for this: You need lazy-loaded images, infinite scroll triggers, animate-on-scroll effects, or tracking which sections the user has scrolled to.

Working Example

"use client";
 
// Lazy-loaded image
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const { ref, isIntersecting } = useIntersectionObserver({
    triggerOnce: true,
    rootMargin: "200px", // Start loading 200px before visible
  });
 
  return (
    <div ref={ref} style={{ minHeight: 200, background: "#f0f0f0" }}>
      {isIntersecting ? (
        <img src={src} alt={alt} style={{ width: "100%" }} />
      ) : (
        <div style={{ padding: 20, color: "#999" }}>Loading...</div>
      )}
    </div>
  );
}
 
// Infinite scroll trigger
function InfiniteList() {
  const [items, setItems] = useState<number[]>([1, 2, 3, 4, 5]);
  const [loading, setLoading] = useState(false);
 
  const { ref, isIntersecting } = useIntersectionObserver({
    threshold: 1.0,
  });
 
  useEffect(() => {
    if (!isIntersecting || loading) return;
    setLoading(true);
 
    // Simulate API call
    setTimeout(() => {
      setItems((prev) => [
        ...prev,
        ...Array.from({ length: 5 }, (_, i) => prev.length + i + 1),
      ]);
      setLoading(false);
    }, 500);
  }, [isIntersecting, loading]);
 
  return (
    <div>
      {items.map((item) => (
        <div key={item} style={{ padding: 24, borderBottom: "1px solid #eee" }}>
          Item {item}
        </div>
      ))}
      <div ref={ref} style={{ padding: 20, textAlign: "center" }}>
        {loading ? "Loading more..." : "Scroll for more"}
      </div>
    </div>
  );
}
 
// Animate on scroll
function AnimatedSection({ children }: { children: React.ReactNode }) {
  const { ref, isIntersecting } = useIntersectionObserver({
    threshold: 0.2,
    triggerOnce: true,
  });
 
  return (
    <div
      ref={ref}
      style={{
        opacity: isIntersecting ? 1 : 0,
        transform: isIntersecting ? "translateY(0)" : "translateY(20px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

What this demonstrates:

  • Lazy image: Only loads when within 200 px of the viewport, fires once
  • Infinite scroll: Triggers data fetching when the sentinel element is fully visible (threshold: 1.0)
  • Animate on scroll: Fades in content when 20% visible, using triggerOnce so it does not replay

Deep Dive

How It Works

  • Callback ref pattern: Instead of a plain useRef, the hook uses a callback ref (node) => .... This lets it re-observe when the target element changes (e.g., conditional rendering).
  • IntersectionObserver: A browser API that asynchronously observes visibility changes. It runs off the main thread, so it is more performant than scroll listeners.
  • triggerOnce: When enabled, the observer disconnects after the first intersection, preventing further callbacks and improving performance for lazy-loaded content.
  • rootMargin: A CSS-like margin string ("200px 0px") that expands the detection area, allowing preloading before the element is visible.
  • threshold: A number (0 to 1) or array of numbers indicating what percentage of the element must be visible to trigger. 0 means any pixel; 1 means fully visible.

Parameters & Return Values

OptionTypeDefaultDescription
rootElement or nullnull (viewport)Scrollable ancestor to use as viewport
rootMarginstring"0px"Margin around root to expand detection
thresholdnumber or number[]0Visibility ratio to trigger
triggerOncebooleanfalseDisconnect after first intersection
enabledbooleantrueWhether to observe
ReturnTypeDescription
ref(node: Element or null) => voidCallback ref to attach to target
entryIntersectionObserverEntry or nullLatest observer entry
isIntersectingbooleanWhether the element is visible

Variations

Multiple elements: Observe many elements with a single observer for better performance:

function useIntersectionObserverMultiple(
  options: IntersectionObserverInit = {}
) {
  const [entries, setEntries] = useState<Map<Element, IntersectionObserverEntry>>(new Map());
  const observer = useRef<IntersectionObserver | null>(null);
 
  const observe = useCallback((node: Element) => {
    if (!observer.current) {
      observer.current = new IntersectionObserver((observed) => {
        setEntries((prev) => {
          const next = new Map(prev);
          observed.forEach((e) => next.set(e.target, e));
          return next;
        });
      }, options);
    }
    observer.current.observe(node);
  }, [options]);
 
  return { observe, entries };
}

With intersection ratio: Track the exact visibility percentage for scroll-linked animations:

const { entry } = useIntersectionObserver({
  threshold: Array.from({ length: 101 }, (_, i) => i / 100),
});
const ratio = entry?.intersectionRatio ?? 0; // 0.0 to 1.0

TypeScript Notes

  • The callback ref accepts Element | null, compatible with any HTML or SVG element.
  • IntersectionObserverEntry is a built-in browser type with isIntersecting, intersectionRatio, boundingClientRect, etc.
  • Options use an interface so they can be extended in consuming hooks.

Gotchas

  • Observer not created during SSRIntersectionObserver does not exist on the server. Fix: The typeof IntersectionObserver === "undefined" guard handles this.
  • Stale entry after unmount — The entry state may reference a disconnected element. Fix: Cleanup disconnects the observer; consumers should guard with isIntersecting.
  • Threshold array creates many callbacks — Using 100 thresholds fires the callback 100 times as the element scrolls through. Fix: Only use fine-grained thresholds when needed for scroll-linked animations.
  • Callback ref re-creates observer — If the callback ref dependencies change, it creates a new observer. Fix: Memoize the options or pass stable references.
  • rootMargin format — Must be a CSS-style string like "10px 20px", not a number. Fix: Validate the format or document it clearly.

Alternatives

PackageHook NameNotes
react-intersection-observeruseInViewMost popular, full-featured
usehooks-tsuseIntersectionObserverSimple, ref-based
ahooksuseInViewportPart of a large collection
@uidotdev/usehooksuseIntersectionObserverMinimal implementation
framer-motionuseInViewAnimation-focused

FAQs

What is the callback ref pattern and why does this hook use it instead of useRef?
  • A callback ref is a function (node) => ... that React calls when the element mounts or changes.
  • Unlike useRef, it lets the hook re-observe when the target element is conditionally rendered.
  • The observer is created inside the callback, ensuring it always targets the correct node.
What does the triggerOnce option do internally?

When triggerOnce is true and the element becomes visible, the hook sets a frozen flag, disconnects the observer, and stops watching. This prevents further callbacks, improving performance for lazy-loaded content.

What is rootMargin and how do I use it for preloading?

rootMargin is a CSS-like margin string (e.g., "200px 0px") that expands the detection area. Setting rootMargin: "200px" triggers intersection 200px before the element enters the viewport, allowing preloading of images or data.

What values can threshold take?
  • A single number from 0 to 1 (e.g., 0.5 means 50% visible).
  • An array of numbers for multiple trigger points (e.g., [0, 0.25, 0.5, 0.75, 1]).
  • 0 means any pixel visible; 1 means fully visible.
Gotcha: I used 100 threshold values and my callback fires excessively. What should I do?

Fine-grained thresholds (e.g., Array.from({ length: 101 }, (_, i) => i / 100)) fire the callback 100 times as the element scrolls through. Only use this for scroll-linked animations. For lazy loading, threshold: 0 or threshold: 1 is sufficient.

Gotcha: The observer does not work during SSR. How is this handled?

The hook guards with typeof IntersectionObserver === "undefined". On the server, it returns early without creating an observer. isIntersecting defaults to false.

How do I observe multiple elements with a single observer?

Use the multi-element variation shown in the Variations section. Create one IntersectionObserver and call .observe(node) for each element. Store entries in a Map keyed by the target element.

Can I use this hook with SVG elements?

Yes. The callback ref accepts Element | null, which covers both HTML and SVG elements. IntersectionObserver works with any DOM element type.

How is IntersectionObserverEntry typed in TypeScript?

IntersectionObserverEntry is a built-in browser type with properties including isIntersecting, intersectionRatio, boundingClientRect, rootBounds, and target. No custom types are needed.

What does the enabled option do?

When enabled is false, the callback ref skips creating an observer. This lets you conditionally pause observation without unmounting the element, and re-enable it later by setting enabled back to true.