useIntersectionObserver
A hook that tracks whether an element is in the viewport.
Installation
Usage
The primitive behind infinite scroll, lazy media, reveal-on-scroll animation, and scroll-spy navigation — all of which are the same question asked with different options.
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
function LoadMore({ onReach }: { onReach: () => void }) {
const { ref } = useIntersectionObserver({
rootMargin: "400px",
onChange: (entry) => entry.isIntersecting && onReach(),
});
return <div ref={ref} aria-hidden className="h-px" />;
}ref is a callback ref, so the element can mount, unmount, and remount — inside a conditional, a
list, or a virtualized viewport — and the observer follows it. A RefObject cannot do that.
Reveal once
once freezes the result after the first intersection and disconnects the observer, which is what
entrance animations want: they should not replay when the user scrolls back up.
const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.3, once: true });
<section ref={ref} data-visible={isIntersecting} className="opacity-0 data-[visible=true]:animate-in">Options
| Option | Description |
|---|---|
root | Scroll container to measure against. Defaults to the viewport. |
rootMargin | Grows or shrinks the root box. Positive values fire early — use for prefetch. |
threshold | Fraction of the element that must be visible. Accepts an array. |
once | Stop observing after the first intersection. |
disabled | Pause observation without unmounting. |
initialIsIntersecting | Value returned before the observer reports. Set true to avoid a flash. |
onChange | Called with each entry. Read from a ref, so it need not be memoized. |
Returns
entry is the raw IntersectionObserverEntry — reach for intersectionRatio or boundingClientRect
when a boolean is not enough, such as a scroll-spy that highlights the most visible heading.