react-intersection-observer
react-intersection-observer wraps the browser Intersection Observer API in React hooks, a render-prop component, and a low-level observe function. It reports when an element crosses visibility thresholds without a scroll handler, reuses native observer instances when options match, and includes test utilities for Jest and Vitest. Version 11 supports React 17 through 19 and offers useOnInView for callback-only tracking that does not update React state or cause a render on each transition.
The default React choice when native Intersection Observer needs clean lifecycle management, state, and credible test support. Skip it when native lazy loading or CSS is enough, or when unsupported-browser fallbacks would distort product behavior.
Use it if
- You need lazy rendering, animation triggers, infinite-list sentinels, or impression tracking tied to viewport entry
- You want React state through useInView or a callback-only path through useOnInView without building observer lifecycle code
- You need several targets with shared options and want observer instances reused automatically
- You test visibility behavior in Jest or Vitest and want maintained IntersectionObserver mocks with threshold controls
- A CSS feature such as content-visibility, animation-timeline, or native loading=lazy already solves the job without React state or JavaScript visibility callbacks
- You need pixel-perfect visibility or overlap detection: Intersection Observer reports threshold crossings asynchronously and is not a layout measurement API
- Your supported runtime has no IntersectionObserver and you will not ship a polyfill or choose fallbackInView; the documented default is to throw an error that can crash the React tree
- You need to know whether an element is covered by another element across all browsers: trackVisibility is experimental, delay must be at least 100 ms, and unsupported browsers are reported as visible
- You pass a plain child to InView but also need a ref to its wrapper: the README says that mode does not support ref forwarding, so use the hook or render-prop form
Setup reality
Install one package and ensure React and React DOM satisfy the declared peer range of 17, 18, or 19. There are no runtime dependencies and TypeScript declarations ship with the package. The real setup issue is browser capability. If IntersectionObserver is missing, the default behavior is to throw; either load a polyfill before observers mount or set a local fallbackInView or global defaultFallbackInView value. That fallback marks every observer the same way, so true can eagerly load or count everything and false can suppress important content. Server rendering starts from initialInView, false by default, because there is no browser entry on the server. A mismatched initial guess can change rendered content after hydration, so keep essential content independent of the flag. The first false notification from the native observer is intentionally suppressed; onChange and useOnInView do not fire merely to restate the initial off-screen state. rootMargin is relative to the configured root, not always the top-level viewport, and iframe behavior follows the native API. Nested clipping containers may need scrollMargin instead. trackVisibility requires delay of at least 100 ms and has incomplete browser support. For tests, jsdom does not provide real layout: import the included test utilities and drive intersections yourself, or use Vitest Browser Mode for actual browser behavior. Non-global Vitest setups must call setupIntersectionMocking and resetIntersectionMocking manually.
Patterns
Read viewport state with useInViewtrack-visibility-state
import { useInView } from 'react-intersection-observer';
export function Section() {
const { ref, inView, entry } = useInView({ threshold: 0.25 });
return (
<section ref={ref}>
Visible: {String(inView)} at {entry?.intersectionRatio ?? 0}
</section>
);
}The initial false observer notification is skipped; later entry and exit transitions update inView normally.
Track an impression without state updatestrack-without-render
import { useOnInView } from 'react-intersection-observer';
function Ad({ id }: { id: string }) {
const ref = useOnInView(
(inView, entry) => {
if (inView) recordImpression(id, entry.time);
},
{ threshold: 0.5, triggerOnce: true },
);
return <aside ref={ref}>Sponsored</aside>;
}useOnInView does not update React state, and it does not accept onChange, initialInView, or fallbackInView options.
Lazy-render content after its first entrytrigger-once
function LazyChart() {
const { ref, inView } = useInView({
rootMargin: '200px 0px',
triggerOnce: true,
});
return <div ref={ref}>{inView ? <Chart /> : <ChartSkeleton />}</div>;
}A positive rootMargin starts loading before the target reaches the viewport; triggerOnce stops observing after entry.
Use a custom scrolling rootobserve-scroll-container
function ResultsPane() {
const rootRef = useRef<HTMLDivElement>(null);
const { ref, inView } = useInView({
root: rootRef.current,
threshold: [0, 0.5, 1],
});
return (
<div ref={rootRef} style={{ overflow: 'auto', height: 320 }}>
<div style={{ height: 800 }} />
<div ref={ref}>{inView ? 'Visible in pane' : 'Outside pane'}</div>
</div>
);
}The root is null on the first render; mounting the target after the root exists avoids observing against the wrong viewport.
Assign a local ref and observer ref togethermerge-react-refs
const localRef = useRef<HTMLDivElement | null>(null);
const { ref: inViewRef, inView } = useInView();
const setRefs = useCallback((node: HTMLDivElement | null) => {
localRef.current = node;
inViewRef(node);
}, [inViewRef]);
return <div ref={setRefs}>Visible: {String(inView)}</div>;Memoize the combined callback so React does not detach and reattach both refs on every render.
Use InView with an explicit observed elementrender-prop-component
import { InView } from 'react-intersection-observer';
<InView threshold={0.5}>
{({ ref, inView, entry }) => (
<article ref={ref} data-ratio={entry?.intersectionRatio}>
{inView ? 'Reading' : 'Not reading'}
</article>
)}
</InView>The render-prop form is the component API to use when you need direct control of the observed element and its ref.
Let InView create a semantic wrapperrender-plain-child
<InView
as='section'
className='feature'
onChange={(inView, entry) => logVisibility(inView, entry.time)}
>
<FeatureCard />
</InView>Plain-child mode always renders its child and does not forward a ref to the generated wrapper.
Choose behavior when IntersectionObserver is absentset-unsupported-fallback
import { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(false);Without a local or global fallback the package throws; a global value applies to every observer in that client.
Temporarily stop observing without clearing statepause-observation
const { ref, inView } = useInView({
skip: modalOpen,
threshold: 0.5,
});
return <div ref={ref}>Last known state: {String(inView)}</div>;Setting skip while the element is in view preserves the current inView state rather than resetting it.
Request Intersection Observer v2 visibilitytrack-actual-visibility
const { ref, entry } = useInView({
trackVisibility: true,
delay: 100,
});
return <div ref={ref}>Actually visible: {String(entry?.isVisible)}</div>;delay must be at least 100 ms; unsupported browsers fall back to reporting isVisible as true.
Drive visibility in a component testmock-intersection-test
import { render, screen } from '@testing-library/react';
import { mockIsIntersecting } from 'react-intersection-observer/test-utils';
render(<Section />);
const target = screen.getByTestId('observed');
mockIsIntersecting(target, 0.5);
expect(screen.getByText('Visible')).toBeInTheDocument();jsdom has no real layout; the helper accepts a boolean or numeric threshold and updates the mocked observer.
Use the low-level observer and clean it upobserve-without-react
import { observe } from 'react-intersection-observer';
const unobserve = observe(
element,
(inView, entry) => console.log(inView, entry.intersectionRatio),
{ threshold: 0.5 },
);
// Later
unobserve();The returned function must be called when the element is no longer needed; hooks normally perform this cleanup for you.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-cool-inview | npm | Choose it when you want a hook with built-in unobserve and richer callback-oriented options |
| react-visibility-sensor | npm | Choose it only for an existing component-based codebase already committed to its older visibility model |
| @uidotdev/usehooks | npm | Choose a broader hook collection when intersection tracking is one of many small browser helpers you need |