Why use it
IntersectionObserver tells you when an element enters or leaves a visible area.
That visible area is usually the browser viewport, but it can also be a scrollable container. That is what makes the API useful. You do not need to attach a scroll listener, measure rectangles on every frame, and write your own visibility math for common cases.
I reach for it when the interface needs to react to presence. A card fades in when it enters the viewport. A nav item highlights when its section becomes current. A video pauses once it leaves the screen. Those are all visibility problems, and IntersectionObserver is built for them.
It is also one of those browser APIs that can replace a surprising amount of custom code. If the question is "is this thing on screen yet?" there is a good chance the observer is the cleaner answer.
Anatomy
The API has four pieces that matter most: observer, target, root, and threshold.
The observer watches one or more target elements. The root defines the box you care about. If you leave root as null, the browser uses the viewport. The threshold decides how much of a target needs to be visible before the callback runs.
That last one is the part worth paying attention to. threshold: 0 means the callback can fire as soon as even a sliver of the element becomes visible. threshold: 0.5 waits until half of it is on screen. threshold: 1 waits until the whole thing fits inside the root.
rootMargin matters too. It lets you expand or shrink the root box before the intersection check happens. A negative bottom margin can delay activation. A positive bottom margin can start work early, which is useful for image loading or prefetching.
The observer
The smallest version looks like this.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log("visible");
}
});
});
observer.observe(element);The callback receives entries, not a single item, because one observer can watch many elements at once. Each entry tells you what changed for that target.
The fields I use most are entry.isIntersecting, entry.intersectionRatio, and entry.target. isIntersecting gives you the simple answer. intersectionRatio tells you how much of the element is visible. target tells you which node the update belongs to.
That is enough for a lot of interfaces. In many cases you do not need the raw scroll position at all. You only need to know when a target crosses a boundary, and the observer already gives you that.
One observer lifecycle
The observer has a small lifecycle: create it, attach it to the targets, respond to entries, then disconnect it when the component leaves the page.
function useIntersectionObserver(
targetRef: React.RefObject<Element | null>,
callback: IntersectionObserverCallback,
options: IntersectionObserverInit,
) {
useEffect(() => {
const target = targetRef.current;
if (!target) {
return;
}
const observer = new IntersectionObserver(callback, options);
observer.observe(target);
return () => observer.disconnect();
}, [callback, options, targetRef]);
}The targetRef owns the node. The callback owns the state change. The options describe the boundary. Keeping those responsibilities separate makes the same hook useful for a sentinel, a video card, or a section heading.
The callback and options should be stable in a real component, usually through useCallback and useMemo. Otherwise, every render can create a new observer even though the visibility rule has not changed.
Loading more posts
One common use is loading more posts at the end of a feed. Instead of checking scrollTop on every movement, you observe a sentinel near the bottom of the list.
This pattern works well because the observer only cares about one boundary. Once the sentinel enters the root, fetch the next batch and append it to the timeline.
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
loadNextPage();
}
},
{
root: feedContainer,
threshold: 0.2,
rootMargin: "0px 0px 20% 0px",
},
);The rootMargin here starts the fetch before the user fully hits the end. That gives the next posts a little time to arrive, which makes the feed feel smoother.
This is one of the clearest observer use cases because the rule is simple: when the loading boundary becomes visible, the feed grows.
Scroll down to see posts enter the viewport
Custom roots
The viewport is not the only root you can observe against. If the content lives inside a scrollable container, you can pass that container as root.
const observer = new IntersectionObserver(callback, {
root: scrollContainer,
threshold: 0.5,
rootMargin: "0px 0px -10% 0px",
});That changes the whole meaning of visibility. Now the target is not being measured against the browser window. It is being measured against the scroll box you passed in.
This is useful for carousels, drawers, side panels, and preview cards where the scrolling happens inside a nested region. It is one of the reasons the API scales well beyond page-level effects.
A signal is not a decision
An observer reports what crossed a boundary. It does not decide which item should become active or what the application should do next.
When several targets intersect at once, the callback needs a policy. Loading more posts can use one sentinel. An autoplay feed may need the item with the strongest ratio. A reading indicator may prefer the heading closest to the root's top edge.
const ratiosRef = useRef(new Map<Element, number>());
function pickMostVisible(ratios: Map<Element, number>) {
return [...ratios.entries()]
.filter(([, ratio]) => ratio > 0)
.sort(([, a], [, b]) => b - a)[0]
?.[0];
}
const ratios = new Map<Element, number>();
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
ratiosRef.current.set(
entry.target,
entry.isIntersecting ? entry.intersectionRatio : 0,
);
});
const nextTarget = pickMostVisible(ratiosRef.current);
if (nextTarget) {
setActiveId((nextTarget as HTMLElement).dataset.id ?? null);
}
});The observer provides the signal. The active-item rule belongs to the interface that consumes it. That separation keeps visibility mechanics independent from decisions like autoplay, highlighting, prefetching, or analytics.
Active posts
Another good fit is deciding which post counts as active inside a media feed.
This is the pattern behind autoplay rules in short-form video feeds. The browser does not need to know the full scroll position. It only needs to know which post owns the strongest intersection inside the feed window.
const ratiosRef = useRef(new Map<Element, number>());
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
ratiosRef.current.set(
entry.target,
entry.isIntersecting ? entry.intersectionRatio : 0,
);
});
const nextPost = [...ratiosRef.current.entries()]
.filter(([, ratio]) => ratio > 0)
.sort(([, a], [, b]) => b - a)[0]?.[0];
if (nextPost) {
setActivePost((nextPost as HTMLElement).dataset.postId!);
}
},
{
root: feedContainer,
threshold: [0.4, 0.7, 0.9],
rootMargin: "0px 0px -15% 0px",
},
);There are a few things going on here. The observer watches several posts at once. More than one can intersect at the same time, so the code picks the one with the highest intersectionRatio. That gives you one clear winner for autoplay, highlighting, or engagement tracking.
The preview also measures the posts inside the root with getBoundingClientRect. The observer tells the component that visibility changed; the root-relative geometry decides which post is closest to the feed's attention zone. That is the same separation as any other interaction model: one layer reports a change, another layer applies the product rule.
I like this approach more than checking offsets by hand. It adapts better when cards have different heights, and it keeps the logic tied to the posts themselves instead of a set of cached measurements.
Scroll down to watch the active post update
Video playback
Another core social-feed pattern is video autoplay. The feed should play the video that is actually primary in the scroll window and pause the rest.
The observer can watch every video card and keep a ratio map. The card with the strongest visible ratio becomes the active video.
const ratios = useRef(new Map<string, number>());
const [activeVideoId, setActiveVideoId] = useState<string | null>(null);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = (entry.target as HTMLElement).dataset.videoId;
if (id) {
ratios.current.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
}
});
const nextVideo = [...ratios.current.entries()]
.filter(([, ratio]) => ratio >= 0.55)
.sort((a, b) => b[1] - a[1])[0]?.[0];
setActiveVideoId(nextVideo ?? null);
},
{
root: feedScroller,
threshold: [0, 0.25, 0.55, 0.75, 0.9],
rootMargin: "-10% 0px -20% 0px",
},
);
posts.map((post) => (
<div
key={post.id}
data-video-id={post.id}
>
<VideoPost playing={activeVideoId === post.id} />
</div>
));The threshold keeps a video from starting when only a sliver is visible. The negative margins make the play zone smaller than the scroll box, which helps the active video feel tied to the center of attention.
The observer is only the invalidation signal here. The ratio map holds the latest visibility value for every video, and the selection policy chooses one active id. That prevents a later callback for one card from accidentally forgetting the other cards still inside the root.
Only the most visible video skeleton plays; the rest pause
Cleanup and reuse
The API is simple, but it still needs cleanup.
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
nodes.forEach((node) => observer.observe(node));
return () => observer.disconnect();
}, [callback, options]);disconnect() matters. Once the component unmounts, you do not want an observer hanging onto old nodes or firing updates into state that no longer exists.
If several targets share the same root, threshold, and callback shape, I prefer using one observer instance for all of them. That keeps setup simpler and usually matches how the UI behaves anyway.
I also try to avoid turning every animation into an observer problem. If the effect only needs to run once on page load, plain CSS is enough. The API is most useful when visibility is the actual trigger.
A small boundary with a large contract
The observer surface can stay small. It needs a root, a set of targets, a visibility rule, and a callback that translates entries into UI state.
type VisibilityBoundary = {
root: Element | null;
rootMargin: string;
threshold: number | number[];
};
function VisibilityAwareFeed({
boundary,
posts,
}: {
boundary: VisibilityBoundary;
posts: Post[];
}) {
const [activePostId, setActivePostId] = useState<string | null>(null);
// The observer watches posts inside boundary.root.
// The callback turns the strongest entry into activePostId.
return posts.map((post) => (
<PostCard
key={post.id}
post={post}
active={post.id === activePostId}
/>
));
}The feed owns the policy. The observer owns visibility updates. The cards only receive the result. That boundary lets you change a threshold without rewriting a card, add a new target without changing the root math, and replace autoplay with prefetching without inventing a second visibility system.
Closing thoughts
IntersectionObserver is a good example of a browser API that removes work instead of adding it.
It gives you a clean answer to a common UI question: when does this element count as visible? Once you have that answer, a lot of patterns get easier to build. Infinite feeds, autoplay rules, sticky navigation, reading progress, and impression tracking all start from the same idea.
The part worth keeping in mind is that the API is about boundaries. Pick the right root, the right threshold, and the right rootMargin, and the behavior tends to fall into place.
