Technical Documentation: thebuilder/react-intersection-observer
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
A React implementation of the Intersection Observer API
that tells you when an element enters or leaves the viewport. Use it for scroll
animations, lazy loading, impression tracking, and infinite scroll. It ships
hooks, render props, and
plain children.
Features
- Hooks or component API -
useInViewfor React state,useOnInViewfor
callbacks, `` for render props and wrapper elements. - Shared observers - Observers with matching options are reused, so watching
many elements stays cheap. - Matches the native API - Options map straight to
IntersectionObserverInit. - Written in TypeScript - Types ship with the package.
- Ready to test - Mocks the Intersection Observer for
Jest and Vitest. - Tree-shakeable - Only the parts you import end up in your bundle.
- Small - Around 1.15kB gzipped for
useInView, 1.6kB for ``.
Installation
npm install react-intersection-observer --saveUsage
`useInView` hook
// Use object destructuring, so you don't need to remember the exact order
const { ref, inView, entry } = useInView(options);
// Or array destructuring, making it easy to customize the field names
const [ref, inView, entry] = useInView(options);Call useInView with the (optional) options you need. It returns aref, the inView status, and the currententry.
Assign the ref to the DOM element you want to watch, and the hook reports the
status.
import React from "react";
import { useInView } from "react-intersection-observer";
const Component = () => {
const { ref, inView, entry } = useInView({
/* Optional options */
threshold: 0,
});
return (
<div ref={ref}>
<h2>{`Header inside viewport ${inView}.`}</h2>
</div>
);
};Note: The first
falsenotification from the underlying IntersectionObserver is ignored so your handlers only run after a real visibility change. Subsequent transitions still report bothtrueandfalsestates as the element enters and leaves the viewport.
`useOnInView` hook
const inViewRef = useOnInView(
(inView, entry) => {
if (inView) {
// Do something with the element that came into view
console.log("Element is in view", entry.target);
} else {
console.log("Element left view", entry.target);
}
},
options // Optional IntersectionObserver options
);useOnInView takes a callback and returns a ref to assign to the DOM element
you want to watch. Whenever the element enters or leaves the viewport, the
callback runs with the latest in-view state.
Differences from useInView:
- No re-renders - The hook holds no state, so a visibility change never
triggers a render. - Direct element access - The callback receives the
IntersectionObserverEntry, including thetargetelement. - Boolean-first callback - The first argument is the current
inView
boolean, matching theonChangesignature fromuseInView. - Same options - Accepts every option
useInViewdoes, exceptonChange,initialInView, andfallbackInView.
Note: Just like
useInView, the initialfalsenotification is skipped. Your callback fires the first time the element becomes visible, then on every enter and leave transition after that.
import React from "react";
import { useOnInView } from "react-intersection-observer";
const Component = () => {
// Track when element appears without causing re-renders
const trackingRef = useOnInView(
(inView, entry) => {
if (inView) {
// Element is in view, so log an impression
console.log("Element appeared in view", entry.target);
} else {
console.log("Element left view", entry.target);
}
},
{
/* Optional options */
threshold: 0.5,
triggerOnce: true,
},
);
return (
<div ref={trackingRef}>
<h2>This element is being tracked without re-renders</h2>
</div>
);
};Render props
Pass `` a function. It runs whenever the state changes, with the new
value of inView. Children also receive a ref that you set on the containing
DOM element. That element is the one the Intersection Observer watches.
TheIntersectionObserverEntry
is available on entry when you need the details of the current intersection
state.
import { InView } from "react-intersection-observer";
const Component = () => (
<InView>
{({ inView, ref, entry }) => (
<div ref={ref}>
<h2>{`Header inside viewport ${inView}.`}</h2>
</div>
)}
</InView>
);
export default Component;Note: `` behaves like the hooks. It suppresses the first
falsenotification, so render props andonChangehandlers only run after a real visibility change.
Plain children
Pass any element to and it creates the wrapping DOM element for you. Add a handler to `onChange` and keep the state in your own component. Extra props on go to the HTML element, so you can set className, style,
and the rest.
import { InView } from "react-intersection-observer";
const Component = () => (
<InView as="div" onChange={(inView, entry) => console.log("Inview:", inView)}>
<h2>Plain children are always rendered. Use onChange to monitor state.</h2>
</InView>
);
export default Component;[!NOTE]
When rendering a plain child, keep your HTML output semantic. Changeasto
match the context, and add aclassNameto style the ``. The
component does not forward refs, so use the render props version if you need arefto the HTML element.
API
Options
Pass these as the options argument to useInView, or as props on ``.
| Name | Type | Default | Description |
|---|---|---|---|
| root | Element |
document |
The element whose bounds count as the viewport for the target. It must be an ancestor of the target. With null, the document viewport is used. |
| rootMargin | string |
'0px' |
Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). Also supports percentages, to check if an element intersects with the center of the viewport for example "-50% 0% -50% 0%". |
| scrollMargin | string |
'0px' |
Margin around nested scroll containers that clip the target. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). Unlike rootMargin, this grows or shrinks every scroll container's clipping rectangle within the root, including the root itself if it is a scroll container. |
| threshold | number or number[] |
0 |
Number between 0 and 1 indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. |
| onChange | (inView, entry) => void |
undefined |
Runs whenever the in view state changes, with the inView boolean and the current IntersectionObserverEntry. |
| trackVisibility | boolean |
false |
Experimental. Track visibility changes on the target, beyond plain intersection. See Intersection Observer v2. |
| delay | number |
undefined |
Experimental. Minimum delay in milliseconds between notifications for a given target. Must be at least 100 if trackVisibility is true. |
| skip | boolean |
false |
Skip creating the IntersectionObserver, so you can turn observation on and off. Setting skip while inView keeps the current state. |
| triggerOnce | boolean |
false |
Only trigger the observer once. |
| initialInView | boolean |
false |
The starting value of inView. Set it to true when the element starts in the viewport and you want to trigger something when it leaves. |
| fallbackInView | boolean |
undefined |
The inView value to use when the client has no IntersectionObserver, instead of the default behavior of throwing. defaultFallbackInView() sets this globally. |
useOnInView accepts the same options as useInView except onChange,initialInView, and fallbackInView.
InView props
The `` component also accepts the following props:
| Name | Type | Default | Description |
|---|---|---|---|
| as | IntrinsicElement |
'div' |
Render the wrapping element as this element. Defaults to div. If you want to use a custom component, use the useInView hook or a render prop instead to manage the reference explicitly. |
| children | ({ref, inView, entry}) => ReactNode or ReactNode |
undefined |
A function receiving inView, a ref to assign to the element root, and the IntersectionObserverEntry as entry. Pass a plain child instead to let `` create the wrapping element. |
Intersection Observer v2
Intersection Observer v2
extends the original API, so you can track whether the element is covered by
another element or has filters applied to it. Useful for blocking clickjacking
attempts or tracking ad exposure.
Add the trackVisibility and delay options, then check whether isVisible
is true on the entry you get back.
const TrackVisible = () => {
const { ref, entry } = useInView({ trackVisibility: true, delay: 100 });
return <div ref={ref}>{entry?.isVisible}</div>;
};Check caniuse for current
browser support. If you set trackVisibility and the browser doesn't support
it, the fallback always reports isVisible as true.
isVisible isn't in the TypeScript lib.d.ts file yet, so you also have to
extend IntersectionObserverEntry with the boolean yourself.
Recipes
A few things you can build with it:
- Lazy image load
- Trigger animations
- Track impressions (Google Analytics, Tag
Manager, etc.)
FAQ
How can I assign multiple refs to a component?
You can wrap multiple ref assignments in a single useCallback:
import React, { useRef, useCallback } from "react";
import { useInView } from "react-intersection-observer";
function Component(props) {
const ref = useRef();
const { ref: inViewRef, inView } = useInView();
// Use `useCallback` so we don't recreate the function on each render
const setRefs = useCallback(
(node) => {
// Refs from `useRef` need the node assigned to `current`
ref.current = node;
// Callback refs, like the one from `useInView`, are functions that take the node
inViewRef(node);
},
[inViewRef],
);
return <div ref={setRefs}>Shared ref is visible: {inView}</div>;
}`rootMargin` isn't working as expected
rootMargin is added to the current root. If your application runs inside an
``, or you defined a custom root, that root is not the viewport.
If a scrollable container inside the root clips the target, use scrollMargin
to change when intersections are calculated for that nested scroll container.
More background:
- Intersection Observer API
- IntersectionObserver scrollMargin
- w3c/IntersectionObserver: rootMargin ignored within iframe
- w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport
- w3c/Support iframe viewport tracking
Testing
[!TIP]
Consider using Vitest Browser Mode instead ofjsdomorhappy-dom.
It runs the browser's own implementation, so intersections trigger correctly when you scroll or add elements to the viewport. You can skipreact-intersection-observer/test-utilsthere, or use it where you need it.
To write meaningful tests, mock the IntersectionObserver. The includedreact-intersection-observer/test-utils does that, and adds a few methods for
faking the inView state. Pass isIntersecting either a boolean or a
threshold between 0 and 1; the mock emulates the real IntersectionObserver so
you can check that your components behave as expected.
| Method | Description |
|---|---|
mockAllIsIntersecting(isIntersecting) |
Set isIntersecting on every current Intersection Observer instance. Pass a boolean or a threshold between 0 and 1. |
mockIsIntersecting(element, isIntersecting) |
Set isIntersecting for the Intersection Observer of one element. Pass a boolean or a threshold between 0 and 1. |
intersectionMockInstance(element) |
Get the mocked IntersectionObserver instance for an element, so you can spy on its observe and unobserve methods. |
setupIntersectionMocking(mockFn) |
Mock the IntersectionObserver. Call it in beforeEach. (Happens automatically in a Jest environment.) |
resetIntersectionMocking() |
Reset the mocks on IntersectionObserver. Call it in afterEach. (Happens automatically in a Jest or Vitest environment.) |
destroyIntersectionMocking() |
Destroy the mock and restore the browser's own window.IntersectionObserver. |
Testing libraries
The test utilities work with both Jest and
Vitest.
Jest
Jest works out of the box. Import react-intersection-observer/test-utils in
your test files and use the mocking methods.
Vitest
With Vitest globals enabled, the
IntersectionObserver is mocked automatically, just like in Jest. Otherwise, set
up and reset the mocking yourself, either in individual tests or in a
setup file.
import { vi, beforeEach, afterEach } from "vitest";
import {
setupIntersectionMocking,
resetIntersectionMocking,
} from "react-intersection-observer/test-utils";
beforeEach(() => {
setupIntersectionMocking(vi.fn);
});
afterEach(() => {
resetIntersectionMocking();
});You only need this if the test environment doesn't expose beforeEach globally
alongside either jest.fn or vi.fn.
Other testing libraries
Follow the Vitest instructions. The same setup and reset code should
work, adapted to your test runner. Failing that, copy
test-utils.ts and make
your own version.
Fallback behavior
You can create a
Jest setup file
that uses the
unsupported fallback
option, then override the IntersectionObserver in the test files where you
import react-intersection-observer/test-utils.
test-setup.js
import { defaultFallbackInView } from "react-intersection-observer";
defaultFallbackInView(true); // or `false`, whichever is right for your appTo mock the Intersection Observer in every test instead, use a global setup
file. Add react-intersection-observer/test-utils to
setupFilesAfterEnv
in the Jest config, or setupFiles in
Vitest.
module.exports = {
setupFilesAfterEnv: ["react-intersection-observer/test-utils"],
};Test example
/* Detailed source-code truncated for AI context efficiency. */Intersection Observer
Intersection Observer
is the API used to determine whether an element is inside the viewport. Every
major browser
supports it natively, Safari
since 12.1.
Add the polyfill if you still support older iOS versions or IE11.
Unsupported fallback
If the client has no IntersectionObserver, the default behavior is to throw an
error. That crashes the React application unless an Error Boundary catches it.
You can instead set a fallback inView value to use whenIntersectionObserver doesn't exist. Make sure your application handles every
observer firing true (or false) at the same time.
You can set the fallback globally:
import { defaultFallbackInView } from "react-intersection-observer";
defaultFallbackInView(true); // or 'false'You can also set the fallback locally on useInView or ``. A local
value overrides the global one.
import React from "react";
import { useInView } from "react-intersection-observer";
const Component = () => {
const { ref, inView, entry } = useInView({
fallbackInView: true,
});
return (
<div ref={ref}>
<h2>{`Header inside viewport ${inView}.`}</h2>
</div>
);
};Polyfill
Import the polyfill
directly, or use a service like
cdnjs.cloudflare.com/polyfill
to add it when needed.
yarn add intersection-observerThen import it in your app:
import "intersection-observer";With Webpack or a similar bundler, use
dynamic imports
to load the polyfill only when it's needed:
/**
* Feature detection, to figure out which polyfills need importing.
**/
async function loadPolyfills() {
if (typeof window.IntersectionObserver === "undefined") {
await import("intersection-observer");
}
}Low-level API
The observe method is the onereact-intersection-observer uses internally to create and destroy
IntersectionObserver instances. Use it when you need full control over when and
how observers are created.
import { observe } from "react-intersection-observer";
const destroy = observe(element, callback, options);| Name | Type | Required | Description |
|---|---|---|---|
| element | Element |
true | DOM element to observe |
| callback | ObserverInstanceCallback |
true | The callback function that Intersection Observer will call |
| options | IntersectionObserverInit |
false | The options for the Intersection Observer |
observe returns an unobserve function. Call it to destroy the observer
again.
[!IMPORTANT]
You most likely won't need this. It's here for handling
IntersectionObservers outside React, or when you need full control over how
instances are created.
Apps Docs Docs Overview (`apps/docs/docs/overview.mdx`)
title: Overview seo: sidebar: label: Overview order: 0
A React implementation of the Intersection Observer API that tells you when an element enters or leaves the viewport. Use hooks for React state or effects, or <InView> for render props and plain children.
Use it to lazy-load content, trigger scroll-based animations, track impressions, prefetch data, highlight visible sections, and load more results.
Install
react-intersection-observerimport { useInView } from "react-intersection-observer";
export function Section() {
const { ref, inView } = useInView();
return <section ref={ref}>{inView ? "In view" : "Waiting"}</section>;
}Attach ref to an element and use inView in the render. The browser viewport is the root by default, and any intersection flips the state. Set a threshold, margin, or custom root when that default is not enough.
entry is the latest observer result, so read geometry from it. It stays undefined until the browser delivers the first accepted notification.
Choose an API
| When you need to | Use | Why |
|---|---|---|
| Change what a component renders | useInView |
Returns a ref, inView, and the latest entry. |
| Run an impression, prefetch, or analytics callback | useOnInView |
Calls your callback without a hook-owned state update. |
| Keep observation close to render props or a wrapper | `` | Provides render props and supports plain children. |
Try it
The hook above uses the browser viewport. This demo adds a custom scroll root and a threshold, so you can see how options change the same inView state.
What you get
- Shared observer instances. Targets that use the same options share one
IntersectionObserver, so observing hundreds of elements is cheap. - Types in the package. Hooks, components, options, and entries are typed. There is no
@typespackage to install. - Two test layers.
react-intersection-observer/test-utilsdrives deterministic transitions, and Browser Mode runs the browser's own observer. - Separate entry points. Import only
useInViewand the rest is tree shaken away, around 1.15kB gzipped.
Where to go next
- Core APIs covers the full contracts and return values.
- Observer options covers thresholds, margins, and scroll containers.
- Recipes builds lazy loading, reveals, impressions, and infinite lists.
- Testing covers verification, and SSR and fallbacks covers server rendering.
Apps Docs Docs Testing Index (`apps/docs/docs/testing/index.mdx`)
title: Testing sidebar: order: 1
Intersection Observer tests fall into two groups. Pick the layer first, then follow the guide for that kind of test.
Choose a test layer
| You need to proveโฆ | Start here |
|---|---|
| Real scrolling, geometry, or native observer behavior | Browser Mode |
| A deterministic callback, state transition, or threshold branch in Vitest or Jest | Mock intersections |
The two work together. Browser Mode verifies browser integration, and the mock produces deterministic observer signals. Keep the global mock setup out of native browser tests.
Organize a mixed suite
Give each layer its own named project so the DOM mock never leaks into native browser tests:
| Behavior | Project | Typical files | Setup |
|---|---|---|---|
| Real scrolling, layout, and native observer delivery | browser |
*.browser.test.tsx |
Vitest Browser Mode, with no global test utilities. |
| Deterministic callbacks and thresholds | dom |
*.dom.test.tsx |
happy-dom or jsdom plus test-utils. |
Both guides show the configuration for their own project.
Apps Docs Evals.Yaml (`apps/docs/evals.yaml`)
questions:
id: install-and-observe-element
question: How do I install the package and observe an element?
expected:- Install the react-intersection-observer package.
- Import and call useInView.
- Attach the returned ref to the element that should be observed.
- inView changes when the element crosses the configured threshold.
- entry is undefined until an observer notification has been accepted.
routes: - /
id: choose-public-api
question: Which API should I use for state, effects, or component composition?
expected:- Use useInView when visibility affects rendered React state.
- Use useOnInView for callbacks such as analytics or prefetching without a hook-owned state update.
- Use InView when render props or a generated wrapper fit the composition.
routes: - /api/core-apis
id: configure-visibility-trigger
question: How can I control when an element counts as visible?
expected:- threshold sets the visible ratio required for inView to become true.
- rootMargin expands or contracts the root bounds so work can start earlier or later.
- scrollMargin adjusts clipping across nested scroll containers.
routes: - /api/configuration
id: observe-inside-scroll-container
question: How do I observe an element inside a custom scroll container?
expected:- The viewport is the default root.
- Pass the ancestor scroll container as root.
- The root must be an ancestor of the observed target.
- Keep the root in state so the hook receives it after React assigns the element.
routes: - /api/configuration
id: stop-or-pause-observation
question: How do I stop after the first view or pause observation?
expected:- triggerOnce stops observing after the first accepted true transition.
- skip temporarily disables observation while preserving the current state.
- onChange is available on useInView and InView.
- Use useOnInView when a callback is the only result needed and no hook-owned state update is wanted.
routes: - /api/configuration
id: deploy-ssr-app-safely
question: What should I decide before deploying an SSR app?
expected:- initialInView controls the state rendered before the first observer notification.
- initialInView does not create an observer or guarantee that the target is visible.
- Hydration attaches the observer after the browser takes over.
- A client without IntersectionObserver throws unless a fallback is configured.
- fallbackInView sets a local fallback, while defaultFallbackInView sets a global policy.
routes: - /guides/ssr
id: enable-observer-v2
question: How do I detect whether intersecting content is actually visible?
expected:- Enable trackVisibility and read entry.isVisible.
- Set delay to at least 100 milliseconds.
- Observer v2 support is narrower than the original Intersection Observer API.
- When isVisible is unsupported, the package approximates it with the calculated intersection state.
- Real occlusion must be verified in a supported browser.
routes: - /guides/intersection-observer-v2
id: test-native-browser-behavior
question: How should I test real scrolling and observer behavior?
expected:- Use Vitest Browser Mode when behavior depends on real layout, scrolling, focus, or native IntersectionObserver delivery.
- Run browser tests in a Chromium project and keep them separate from DOM-emulated tests that use the package mock.
- Native IntersectionObserver delivery is asynchronous, so assertions should wait for the update.
- Do not load react-intersection-observer/test-utils globally in the Browser Mode project because it replaces the native API.
routes: - /testing/browser-mode
id: mock-intersection-transitions
question: How do I test intersection transitions deterministically?
expected:- Import utilities from react-intersection-observer/test-utils in a DOM-capable test environment.
- Use mockAllIsIntersecting or mockIsIntersecting to deliver controlled observer entries.
- Boolean values simulate entering or leaving, while numbers cross configured thresholds.
- The mock tests callbacks and state transitions but does not prove physical layout or native browser geometry.
- Reset observed elements and mock calls between tests.
routes: - /testing/mocking-intersections
id: build-accessible-infinite-scroll
question: How do I build accessible infinite scrolling?
expected:- Observe a sentinel at the end of the list.
- Use skip while loading, after the last page, or on error to prevent duplicate requests.
- rootMargin can begin loading before the sentinel reaches the viewport.
- Keep a manual Load more action for keyboard and assistive-technology users.
- Provide a retry boundary instead of making scrolling the only way to fetch content.
routes: - /guides/recipes
Apps Docs Package.Json (`apps/docs/package.json`)
{
"name": "docs",
"private": true,
"scripts": {
"dev": "blume dev",
"build": "ASTRO_TELEMETRY_DISABLED=1 blume build",
"lint": "biome check .",
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 blume check",
"validate": "ASTRO_TELEMETRY_DISABLED=1 blume validate",
"og": "node scripts/generate-og.mjs"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-intersection-observer": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "^2.5.6",
"blume": "^2.2.3",
"takumi-js": "^2.5.4"
}
}
Apps Docs Docs Api Configuration (`apps/docs/docs/api/configuration.mdx`)
title: Observer options
Most components only need to decide when an element counts as visible, where to observe it, and what happens after the first notification. Those three sections come first. The full option reference is at the end.
Attach ref to the target. The browser viewport is the root by default. An intersection happens when the target crosses that root's bounds, and thresholds and margins move where that line sits.
Choose when it counts as visible
Use threshold when the amount of visible content matters. A threshold of 0.5 means the target must be at least half visible before inView becomes true:
useInView({ threshold: 0.5 });Use rootMargin to start earlier or later than the target's actual edge. This is useful for preloading:
useInView({
rootMargin: "200px 0px",
triggerOnce: true,
});scrollMargin grows or shrinks the clipping rectangles of scroll containers nested inside the root.
Choose where to observe
Leave root out, or set it to null, to observe against the browser viewport. To observe inside a scroll container, pass that container as root. It has to be an ancestor of the target, and it needs a bounded scrolling area. Keep it in state so the hook gets the element after React assigns it:
import { useState } from "react";
import { useInView } from "react-intersection-observer";
export function ScrollArea() {
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const { ref, inView } = useInView({ root });
return (
<div
ref={setRoot}
style={{ blockSize: 240, overflowY: "auto" }}
>
<div style={{ blockSize: 220 }} />
<article ref={ref}>
{inView ? "Visible in this container" : "Waiting"}
</article>
<div style={{ blockSize: 220 }} />
</div>
);
}When an inner scroller clips the target, add scrollMargin, as in useInView({ root, scrollMargin: "80px 0px" }). Watch out for the things that quietly change what the browser considers visible: invalid margin syntax, thresholds outside 0 to 1, iframes, and custom roots.
:::note[Verify configuration]
Test real layout, scrolling, and geometry in Vitest Browser Mode. To cross a configured threshold without depending on layout, use a deterministic mock. See Mock intersections.
:::
Stop or pause observation
triggerOnce stops observing after the first accepted true transition. skip disables observation and keeps the current state.
Use onChange with useInView or `` when you want both React state and a callback. Use useOnInView when the callback is all you need.
const { ref, inView } = useInView({
triggerOnce: true,
skip: isSaving,
onChange(nextInView, entry) {
console.log(nextInView, entry.target);
},
});onChange runs alongside the state update.
Initial, server, and unsupported-client state
initialInView sets the state before any observer reports. fallbackInView sets a value only when the client has no IntersectionObserver, and defaultFallbackInView sets that value for the whole application.
Both affect server rendering and unsupported browsers. Decide the policy once in SSR and fallbacks instead of spreading it across individual components.
Option reference
The observation options work with all three APIs. onChange, initialInView, and fallbackInView only work with useInView and ``.
| Option | Default | Purpose |
|---|---|---|
root |
null |
The viewport or an ancestor scroll container. |
rootMargin |
"0px" |
Expand or contract the root bounds. |
scrollMargin |
"0px" |
Adjust clipping across nested scroll containers. |
threshold |
0 |
A number or array of ratios from 0 to 1. |
triggerOnce |
false |
Stop observing after the first accepted enter transition. |
skip |
false |
Disable observation while preserving the current state. |
onChange |
undefined |
Run (inView, entry) after an accepted transition. |
initialInView |
false |
Set initial state before observer delivery. |
fallbackInView |
undefined |
Set state when the API is unavailable. |
trackVisibility |
false |
Experimental. Ask browsers that support Observer v2 for entry.isVisible, which goes beyond geometric intersection. |
delay |
undefined |
Minimum delay between v2 visibility notifications. Only valid with trackVisibility, and must be at least 100ms. |
Experimental v2 options
Most applications only need inView, which means the target intersects the root. Set trackVisibility: true when you need Observer v2's entry.isVisible field instead, which also accounts for an intersecting target being covered or filtered. Pair it with a delay of at least 100 milliseconds:
useInView({ trackVisibility: true, delay: 100 });Browser support for v2 is narrower than for the original API. Read Observer v2 before you base viewability or occlusion decisions on isVisible.
Apps Docs Docs Api Core Apis (`apps/docs/docs/api/core-apis.mdx`)
title: Core APIs
react-intersection-observer exports two hooks and one component. They take the same observer options and differ only in how they hand you the result.
| API | Use it when | What it returns |
|---|---|---|
useInView |
Visibility affects rendered output. | A callback ref, inView, and the latest entry. |
useOnInView |
You need an effect without a hook-owned state update. | A callback ref and your (inView, entry) callback. |
| `` | Render props or a wrapper component fit the composition. | Render-prop fields, or a generated wrapper for plain children. |
:::note[Initial notification]
All three APIs suppress the observer's first false result. initialInView sets the starting state and changes how that first notification is handled. Every enter and leave transition after it reports both true and false.
:::
There is also a low-level observe function for code that already owns a DOM element.
Common configuration choices
- Load or reveal once with
triggerOnce. - Start work before the target reaches the viewport with
rootMargin. - Require a meaningful amount of content to be visible with
threshold. - Observe inside a scrolling container with
root. - Use
useOnInViewfor analytics or prefetching that should not create a hook-owned re-render.
`useInView`
Use useInView when visibility belongs in React state:
import { useInView } from "react-intersection-observer";
export function ArticleSection() {
const { ref, inView } = useInView();
return <section ref={ref}>{inView ? "In view" : "Waiting"}</section>;
}The hook supports both object and tuple destructuring:
const { ref, inView, entry } = useInView();
const [ref, inView, entry] = useInView();entry is undefined until the first accepted notification. After that it is the latest IntersectionObserverEntry, so read intersectionRatio, boundingClientRect, or target from it when the UI needs geometry.
`onChange`
useInView can call onChange alongside its state update:
const { ref, inView } = useInView({
onChange(nextInView, entry) {
console.log(entry.target, nextInView);
},
});Use useOnInView instead when the callback is the only result you need.
`useOnInView`
useOnInView returns a ref callback and never updates component state. Use it for analytics, logging, prefetching, and anything else that should not cause a render:
import { useOnInView } from "react-intersection-observer";
export function TrackedCard({ id }: { id: string }) {
const ref = useOnInView(
(inView, entry) => {
if (inView) {
analytics.track("card_visible", { id, target: entry.target });
}
},
{ threshold: 0.5, triggerOnce: true },
);
return <article ref={ref}>Card {id}</article>;
}The callback receives (inView, entry). useOnInView accepts the options that affect observation: root, rootMargin, scrollMargin, threshold, triggerOnce, skip, trackVisibility, and delay. It does not accept onChange, initialInView, or fallbackInView.
``
Use render props when the component should receive the ref and visibility state directly:
import { InView } from "react-intersection-observer";
export function RevealCard() {
return (
<InView threshold={0.2} triggerOnce>
{({ ref, inView, entry }) => (
<article ref={ref} className={inView ? "card visible" : "card"}>
<h2>Card</h2>
<p>{entry ? "The observer has reported" : "Waiting"}</p>
</article>
)}
</InView>
);
}The render prop receives { ref, inView, entry }. Attach the ref to the element you want to observe.
Plain children
Plain children always render. In this form `` creates a wrapper element, forwards any extra HTML props to it, and observes it:
<InView as="section" className="article" onChange={handleChange}>
<h2>Always-rendered content</h2>
</InView>Use as to keep the generated wrapper semantic. This form does not forward refs. Use render props or useInView when you need direct control of the observed element, or a ref on a custom component.
Low-level `observe`
When you already own a DOM element outside React rendering, use the low-level observe function. Keep the cleanup function it returns:
import { observe } from "react-intersection-observer";
const stop = observe(
element,
(inView, entry) => {
console.log(inView, entry.intersectionRatio);
},
{ threshold: 0.5 },
);
// Call this when the element is no longer relevant.
stop();Shared behavior
triggerOncestops observing after the first acceptedtruetransition.skipdisables observation while preserving the current state.- When an observed node is removed and later replaced, the hook resets to
initialInView, unlesstriggerOnceorskipprevents the reset. entryisundefinedbefore the first accepted notification, and again after an observed node resets.
See Configuration for every option and Testing for deterministic observer transitions.
Apps Docs Docs Guides Intersection Observer V2 (`apps/docs/docs/guides/intersection-observer-v2.mdx`)
title: Observer v2 sidebar: label: Observer v2 badge: Experimental
Most applications should stick with the standard inView signal. Intersection Observer v2 adds trackVisibility and entry.isVisible for viewability cases where an intersecting element may be covered by something else or hidden behind a filter.
Enable visibility tracking
Set trackVisibility: true and a delay of at least 100 milliseconds:
import { useOnInView } from "react-intersection-observer";
declare global {
interface IntersectionObserverEntry {
isVisible?: boolean;
}
}
export function Viewability({ onSeen }: { onSeen: () => void }) {
const ref = useOnInView(
(inView, entry) => {
if (inView && entry.isVisible === true) onSeen();
},
{
trackVisibility: true,
delay: 100,
threshold: 0.5,
triggerOnce: true,
},
);
return <div ref={ref}>Measured content</div>;
}isVisible is not in TypeScript's IntersectionObserverEntry declaration yet, so add the augmentation once in your application types before reading it. The option works with all three APIs. With useOnInView, read entry.isVisible inside the callback. With ``, read it from the render-prop entry or from onChange.
Support and fallback behavior
Fewer browsers support v2 than the original API. When a browser has Intersection Observer but no isVisible, this package falls back to v1 and sets entry.isVisible to the calculated intersection state. Treat that value as an approximation there.
Missing isVisible and a missing observer are separate problems. If the whole IntersectionObserver API is unavailable, use fallbackInView or defaultFallbackInView as described in Configuration.
Test it in a real browser
Verify geometry, clipping, and real occlusion in a real browser. The package mock can drive the v1 fallback branch and your callback, but it cannot cover one element with another, so it can never prove occlusion. In deterministic tests, assert the application behavior you need instead.
Keep the minimum delay in shared configuration so a future browser or package update cannot quietly make the observer invalid.
Apps Docs Docs Guides Recipes (`apps/docs/docs/guides/recipes.mdx`)
title: Recipes
Each recipe states the problem, shows a complete component, and calls out the part that is easiest to get wrong. For the API details behind the examples, see Core APIs and Configuration.
Lazy image loading
:::tip[Prefer native lazy loading]
Add loading="lazy" to ordinary image elements and the browser defers loading for you, with no observer code.
:::
Reach for the observer when you need a specific preload margin, a custom placeholder, or a loading transition on the component.
import { useInView } from "react-intersection-observer";
type LazyImageProps = {
src: string;
alt: string;
width: number;
height: number;
};
export function LazyImage({ src, alt, width, height }: LazyImageProps) {
const { ref, inView } = useInView({
rootMargin: "200px 0px",
triggerOnce: true,
});
return (
<div ref={ref} style={{ aspectRatio: `${width} / ${height}` }}>
{inView ? (
<img src={src} alt={alt} width={width} height={height} />
) : (
<div aria-hidden="true" className="image-placeholder" />
)}
</div>
);
}Reserve the image's space with dimensions or an aspect ratio. Without it the image shifts the page when it loads, and the observer keeps chasing a moving target.
Scroll-triggered animation
Keep the animation in CSS and use triggerOnce when the reveal should happen only once:
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
export function Reveal({ children }: { children: React.ReactNode }) {
const [enhanced, setEnhanced] = useState(false);
const { ref, inView } = useInView({
fallbackInView: true,
threshold: 0.2,
triggerOnce: true,
});
useEffect(() => {
setEnhanced("IntersectionObserver" in window);
}, []);
return (
<div
ref={ref}
className={enhanced && !inView ? "reveal reveal-pending" : "reveal"}
>
{children}
</div>
);
}.reveal {
opacity: 1;
transform: none;
transition: opacity 250ms ease, transform 250ms ease;
}
.reveal-pending {
opacity: 0;
transform: translateY(1rem);
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}The content stays visible until the observer takes over, and fallbackInView: true keeps it visible when observation never arrives. Never make the animation the only way to find important content. Reduced-motion users see it right away.
Track an impression
Use useOnInView when the result is an effect and a state update is unnecessary:
import { useOnInView } from "react-intersection-observer";
type ImpressionProps = {
id: string;
record: (id: string) => void;
};
export function Impression({ id, record }: ImpressionProps) {
const ref = useOnInView(
(inView) => {
if (inView) record(id);
},
{ threshold: 0.5, triggerOnce: true },
);
return <article ref={ref}>Tracked content</article>;
}The package ignores the initial false notification, so this callback records the first accepted enter transition. Keep the identifier stable, and check that triggerOnce matches what your analytics counts as an impression.
An intersection does not prove anyone actually looked at the content. Add a minimum-duration rule if your definition requires time in view, and look at Observer v2 if covered or filtered content matters to you.
Infinite scrolling
Observe a sentinel at the end of the list, guard requests while one is in flight, and add a real button for keyboard and assistive-technology users:
/* Detailed source-code truncated for AI context efficiency. */:::note[React 19]
Passing an async function to startTransition needs React 19. On React 18 isPending clears at the first await, so track a loading status yourself there.
:::
The sentinel starts inside the viewport, so the observer loads the first page too.
skip stops a second request while one is in flight. It also re-arms the sentinel, because the hook drops and recreates the observer each time skip flips back, so a page too short to fill the viewport keeps loading.
rootMargin starts the request before the sentinel is visible. Tune 400px to your network and item size, and send whatever page key your API expects instead of items.length.
Scrolling should never be the only way to load more.
Apps Docs Docs Guides Ssr (`apps/docs/docs/guides/ssr.mdx`)
title: SSR and fallbacks
Intersection Observer only exists in a browser, so decide three things together: what the server renders, what may change after hydration, and how a client without the API should behave. initialInView covers the first, fallbackInView covers one component in the third, and defaultFallbackInView sets that policy for the whole application.
Choose the initial policy
Use initialInView when you already know how a component should render before the first observer notification:
const { ref, inView } = useInView({
initialInView: true,
});This helps when above-the-fold content should start out visible. It only controls the render before the observer reports. It does not create an observer, and it does not guarantee the target is visible.
Fallback for unsupported clients
If the observer API is missing, the default behavior is to throw. Set a local fallback when the decision belongs to one component:
const { ref, inView } = useInView({
fallbackInView: false,
});Set a global policy when the whole application should behave consistently:
import { defaultFallbackInView } from "react-intersection-observer";
defaultFallbackInView(false);Pick true when content should count as visible even without observation. Pick false when lazy work must not start on its own. Either way, check that the result is safe for every observer in the application, because a global fallback applies to every instance that does not set its own value.
Hydration
Hydration attaches the observer once the browser takes over, so nothing is observed during the server render. entry is undefined until an accepted notification arrives, and reading geometry from it on the server will fail.
If the server renders hidden content and the client immediately renders it visible, reserve the layout space and keep the change accessible. Give images dimensions and consider native loading="lazy". For content that matters, make sure it is still reachable when observation is unavailable.
Apps Docs Docs Testing Browser Mode (`apps/docs/docs/testing/browser-mode.mdx`)
title: Browser Mode
Vitest Browser Mode runs your component test in a real browser instead of a simulated DOM. The test gets real viewport geometry, CSS layout, scrolling, focus, and the browser's own IntersectionObserver.
Use it when the browser is part of what you need to prove. An element entering a scroll container, a rootMargin starting work early, a layout change moving a target, the native observer delivering an update: none of those are provable against a fake DOM.
Keep Browser Mode separate from mocked DOM tests
Run Browser Mode tests in a Chromium browser project, separate from the happy-dom or jsdom tests that load react-intersection-observer/test-utils. The native observer has to stay in place, or the test proves nothing about scrolling, layout, or observer delivery.
Install and configure Browser Mode
Install Vitest's Playwright provider, the React renderer, and a browser:
pnpm add -D vitest vitest-browser-react @vitest/browser-playwright playwright
pnpm exec playwright install chromiumConfigure the test files and provider. Keep the include pattern narrow so these tests stay separate from the rest of the suite:
// vitest.config.ts
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});Run the browser tests with your normal Vitest command. For CI, add headless: true inside browser. Without it, Vitest opens its browser UI, which is what you want locally.
If the repository also has deterministic DOM tests, add Browser Mode as one named project. The testing overview covers that layout.
Test a real scroll transition
Use Vitest's React Browser API to render components and make retriable assertions in the browser. This example proves both sides of the interaction. The target starts outside the viewport, and only becomes visible after real scrolling changes the layout.
import { render } from "vitest-browser-react";
import { expect, test } from "vitest";
import { useInView } from "react-intersection-observer";
function RevealOnScroll() {
const [ref, inView] = useInView();
return (
<div ref={ref} data-inview={inView} style={{ height: 200 }}>
Observed target
</div>
);
}
test("updates when the target scrolls into the viewport", async () => {
const screen = await render(
<>
<div style={{ height: window.innerHeight }} />
<RevealOnScroll />
<div style={{ height: window.innerHeight }} />
</>,
);
const target = screen.getByText("Observed target");
await expect.element(target).toHaveAttribute("data-inview", "false");
window.scrollTo(0, window.innerHeight);
// Native IntersectionObserver delivery follows the scroll asynchronously.
await expect.element(target).toHaveAttribute("data-inview", "true");
});expect.element retries in the browser, so the final assertion waits for the observer callback instead of guessing at a timer value.
Keep the native observer native
Do not import react-intersection-observer/test-utils in Browser Mode setup. Its mock replaces the browser API, and the test stops proving anything about scrolling, layout, or native observer delivery.
For a callback or threshold branch that does not need real geometry, use mock intersections. That guide covers happy-dom, jsdom, Jest, Vitest, and both automatic and manual setup.
Apps Docs Docs Testing Mocking Intersections (`apps/docs/docs/testing/mocking-intersections.mdx`)
title: Mock intersections
Use react-intersection-observer/test-utils when the assertion is about a callback, state transition, or threshold branch rather than real layout. The utilities replace window.IntersectionObserver, remember which elements each mock observer watches, and let a helper deliver controlled entries to the registered callback.
Set up the mock
Importing test-utils in a DOM test environment can set the mock up for you. When the module finds global beforeEach, afterEach, and either vi or jest, it registers its own hooks. The beforeEach hook installs the observer mock with that runner's fn. The afterEach hook clears both the mock calls and the observed elements, so nothing carries between tests. Put the import in a setup file so every relevant test gets the same lifecycle.
Install a DOM emulator first. happy-dom is usually faster, and jsdom may fit an existing suite better. Neither one proves layout, scrolling, or native observer delivery.
pnpm add -D happy-dom
# or: pnpm add -D jsdom// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "happy-dom", // "jsdom" works too
setupFiles: ["./test/setup.ts"],
},
});
// test/setup.ts
import "react-intersection-observer/test-utils";// jest.config.js
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["react-intersection-observer/test-utils"],
};The mock does not care which runner you use. Jest is not tied to jsdom, and Vitest works with either jsdom or happy-dom. In a mixed Vitest suite, keep this setup in a dom project so it never reaches the Browser Mode tests.
Manual setup without globals
If your runner does not expose global test APIs, or you prefer importing them, register the lifecycle yourself. setupIntersectionMocking takes the runner's mock factory, and resetIntersectionMocking clears it after every test.
import { afterEach, beforeEach, vi } from "vitest";
import {
resetIntersectionMocking,
setupIntersectionMocking,
} from "react-intersection-observer/test-utils";
beforeEach(() => setupIntersectionMocking(vi.fn));
afterEach(resetIntersectionMocking);import { afterEach, beforeEach, jest } from "@jest/globals";
import {
resetIntersectionMocking,
setupIntersectionMocking,
} from "react-intersection-observer/test-utils";
beforeEach(() => setupIntersectionMocking(jest.fn));
afterEach(resetIntersectionMocking);Drive a transition
With either setup in place, import a helper and trigger the observer state you need:
import { render } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { useOnInView } from "react-intersection-observer";
import { mockAllIsIntersecting } from "react-intersection-observer/test-utils";
function Probe({ onSeen }: { onSeen: (inView: boolean) => void }) {
const ref = useOnInView((inView) => onSeen(inView));
return <div ref={ref}>Observed</div>;
}
test("ignores the initial false notification", () => {
const onSeen = vi.fn();
render(<Probe onSeen={onSeen} />);
mockAllIsIntersecting(false);
expect(onSeen).not.toHaveBeenCalled();
mockAllIsIntersecting(true);
expect(onSeen).toHaveBeenCalledWith(true);
});Pass a boolean to enter or leave. A number selects which of the configured thresholds the observer has crossed, and the mock reports the highest crossed one as entry.intersectionRatio. Assert against the thresholds you configured.
These examples work the same in Vitest and Jest. Switch to Browser Mode when a test has to prove real browser behavior.
Utilities
| Utility | Use |
|---|---|
mockAllIsIntersecting(value) |
Trigger every observed element. |
mockIsIntersecting(element, value) |
Trigger one observed element. |
intersectionMockInstance(element) |
Inspect the mock observer for an element. |
setupIntersectionMocking(mockFn) |
Install the mock yourself with vi.fn, jest.fn, or a compatible factory. |
resetIntersectionMocking() |
Clear observed elements and mock calls between tests. |
destroyIntersectionMocking() |
Restore the native observer after mocking it. |
:::note
The mock can drive a trackVisibility fallback branch, but it cannot prove real v2 occlusion. Treat that API as experimental, and verify it in a supported browser if your product depends on it.
:::
--- METRICS ---
- Files Extracted: 12
- Estimated Token Budget: ~16009 tokens
- Canonical Reference: https://codewiki.google/github.com/thebuilder/react-intersection-observer