react-intersection-observer Documentation

React implementation of the Intersection Observer API to tell you when an element enters or leaves the viewport.

RAW Doc

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 - useInView for React state, useOnInView for
    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

sh
npm install react-intersection-observer --save

Usage

`useInView` hook

js
// 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 a
ref, the inView status, and the current
entry.
Assign the ref to the DOM element you want to watch, and the hook reports the
status.

jsx
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 false notification from the underlying IntersectionObserver is ignored so your handlers only run after a real visibility change. Subsequent transitions still report both true and false states as the element enters and leaves the viewport.

`useOnInView` hook

js
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 the target element.
  • Boolean-first callback - The first argument is the current inView
    boolean, matching the onChange signature from useInView.
  • Same options - Accepts every option useInView does, except
    onChange, initialInView, and fallbackInView.

Note: Just like useInView, the initial false notification is skipped. Your callback fires the first time the element becomes visible, then on every enter and leave transition after that.

jsx
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.

The
IntersectionObserverEntry
is available on entry when you need the details of the current intersection
state.

jsx
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 false notification, so render props and onChange handlers 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.

jsx
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. Change as to
match the context, and add a className to style the ``. The
component does not forward refs, so use the render props version if you need a
ref to 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.

jsx
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:

FAQ

How can I assign multiple refs to a component?

You can wrap multiple ref assignments in a single useCallback:

jsx
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:

Testing

[!TIP]
Consider using Vitest Browser Mode instead of jsdom or happy-dom.
It runs the browser's own implementation, so intersections trigger correctly when you scroll or add elements to the viewport. You can skip react-intersection-observer/test-utils there, or use it where you need it.

To write meaningful tests, mock the IntersectionObserver. The included
react-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.

js
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

js
import { defaultFallbackInView } from "react-intersection-observer";

defaultFallbackInView(true); // or `false`, whichever is right for your app

To 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.

js
module.exports = {
  setupFilesAfterEnv: ["react-intersection-observer/test-utils"],
};

Test example

text
/* 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 when
IntersectionObserver doesn't exist. Make sure your application handles every
observer firing true (or false) at the same time.

You can set the fallback globally:

js
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.

jsx
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.

sh
yarn add intersection-observer

Then import it in your app:

js
import "intersection-observer";

With Webpack or a similar bundler, use
dynamic imports
to load the polyfill only when it's needed:

js
/**
 * 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 one
react-intersection-observer uses internally to create and destroy
IntersectionObserver instances. Use it when you need full control over when and
how observers are created.

js
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

package-install
react-intersection-observer
tsx
import { 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 @types package to install.
  • Two test layers. react-intersection-observer/test-utils drives deterministic transitions, and Browser Mode runs the browser's own observer.
  • Separate entry points. Import only useInView and the rest is tree shaken away, around 1.15kB gzipped.

Where to go next


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:

tsx
useInView({ threshold: 0.5 });

Use rootMargin to start earlier or later than the target's actual edge. This is useful for preloading:

tsx
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:

tsx
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.

tsx
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:

tsx
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

`useInView`

Use useInView when visibility belongs in React state:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
<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:

ts
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

  • triggerOnce stops observing after the first accepted true transition.
  • skip disables observation while preserving the current state.
  • When an observed node is removed and later replaced, the hook resets to initialInView, unless triggerOnce or skip prevents the reset.
  • entry is undefined before 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:

tsx
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.

tsx
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:

tsx
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>
  );
}
css
.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:

tsx
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:

text
/* 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:

tsx
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:

tsx
const { ref, inView } = useInView({
  fallbackInView: false,
});

Set a global policy when the whole application should behave consistently:

ts
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:

bash
pnpm add -D vitest vitest-browser-react @vitest/browser-playwright playwright
pnpm exec playwright install chromium

Configure the test files and provider. Keep the include pattern narrow so these tests stay separate from the rest of the suite:

ts
// 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.

tsx
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.

bash
pnpm add -D happy-dom
# or: pnpm add -D jsdom
ts vitest
// 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";
js jest + jsdom
// 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.

ts vitest
import { afterEach, beforeEach, vi } from "vitest";
import {
  resetIntersectionMocking,
  setupIntersectionMocking,
} from "react-intersection-observer/test-utils";

beforeEach(() => setupIntersectionMocking(vi.fn));
afterEach(resetIntersectionMocking);
ts jest
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:

tsx
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 ---