566

React useIntersectionObserver: viewport tracking made easy

Detect when an element enters a viewport or scroll container, preload work early, and reuse the same observer state from a tiny hook contract.

September 14, 2026

useIntersectionObserver gives React a clean way to know when an element crosses a viewport boundary. No scroll listeners, no manual rectangle math, no guessing whether a card is "close enough" to load. You attach a ref, read entries, and let the browser's IntersectionObserver do the measuring.

That makes the hook a good fit for lazy media, infinite lists, scrollspy navigation, analytics impressions, and any UI that should react to "this thing is now visible."

The smallest useful version

Call the hook with no arguments and it gives you a ref. Attach it to the element you care about:

import { useIntersectionObserver } from '@siberiacancode/reactuse';
 
function LazyVideo() {
  const intersection = useIntersectionObserver<HTMLDivElement>({
    rootMargin: '300px'
  });
 
  const isNearViewport = intersection.entries?.some((entry) => entry.isIntersecting) ?? false;
 
  return (
    <div ref={intersection.ref}>
      {isNearViewport ? (
        <video controls preload='metadata' src='/videos/demo.mp4' />
      ) : (
        <div className='video-placeholder' />
      )}
    </div>
  );
}

rootMargin expands the observed area, so the video can start loading before the user reaches it. The component still reads like normal React state: before intersection, render the placeholder; after intersection, render the expensive thing.

Run work when visibility changes

If the visibility event should trigger a side effect, pass onChange. The callback receives the raw entries array and the observer instance, so one-time work can disconnect itself after it fires:

import { useIntersectionObserver } from '@siberiacancode/reactuse';
import { useRef } from 'react';
 
function LoadMoreSentinel({ onLoadMore }: { onLoadMore: () => void }) {
  const loadedRef = useRef(false);
 
  const { ref } = useIntersectionObserver<HTMLDivElement>({
    rootMargin: '240px 0px',
    onChange: (entries, observer) => {
      const [entry] = entries;
      if (!entry?.isIntersecting || loadedRef.current) return;
 
      loadedRef.current = true;
      onLoadMore();
      observer.disconnect();
    }
  });
 
  return <div ref={ref} aria-busy='true' />;
}

This is the shape you want for "load the next page", "mark an impression", or "start an animation once." The browser tells you when the sentinel is close; the hook keeps the observer lifecycle inside React.

Pass an existing target

Sometimes the component already owns the ref. Pass it as the first argument and the hook returns just the observer state:

import { useIntersectionObserver } from '@siberiacancode/reactuse';
import { useRef } from 'react';
 
function HeroVisibility() {
  const heroRef = useRef<HTMLDivElement>(null);
 
  const { entries } = useIntersectionObserver(heroRef, {
    threshold: 0.5
  });
 
  const isMostlyVisible = entries?.[0]?.isIntersecting ?? false;
 
  return (
    <>
      <div ref={heroRef}>Hero</div>
      <button disabled={!isMostlyVisible}>Continue</button>
    </>
  );
}

The overload matters because hooks often live inside components that already have DOM refs for focus, layout, animation, or measurement. useIntersectionObserver does not force you into a second ref just to use the browser API.

Observe inside a scroll container

The default root is the document. For panels, sidebars, command menus, and docs pages, pass a root target so intersection is measured inside that container instead:

import { useIntersectionObserver } from '@siberiacancode/reactuse';
import { useRef, useState } from 'react';
 
const SECTIONS = ['Intro', 'Install', 'API', 'Examples'];
 
function ScrollSpy() {
  const rootRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(SECTIONS[0]);
 
  const intersection = useIntersectionObserver(rootRef, {
    root: rootRef,
    rootMargin: '-40% 0px -40% 0px',
    threshold: 0,
    onChange: (entries) => {
      const visible = entries.find((entry) => entry.isIntersecting);
      if (visible) setActive(String(visible.target.id));
    }
  });
 
  const observeSection = (element: HTMLElement | null) => {
    if (element && intersection.observer) intersection.observer.observe(element);
  };
 
  return (
    <div className='docs-layout'>
      <nav>{active}</nav>
 
      <div ref={rootRef} className='docs-scroll'>
        {SECTIONS.map((section) => (
          <section key={section} ref={observeSection} id={section}>
            {section}
          </section>
        ))}
      </div>
    </div>
  );
}

That is the nice part of exposing observer: the hook can manage the observer lifecycle, while your component can still attach more observed elements when the UI needs a small scrollspy or table of contents.

Pause the observer

Use enabled: false when the observer should exist only after a condition is true:

const intersection = useIntersectionObserver<HTMLDivElement>({
  enabled: open,
  onChange: (entries) => {
    if (entries.some((entry) => entry.isIntersecting)) {
      prefetchDetails();
    }
  }
});

This is useful for tabs, modals, virtualized panels, and any UI where the target may mount before the feature is actually active.