mrkeyoor.com_
Sat 08 Aug 20:58 UTC
npmWeb Frontendupdated 08 Aug 2026

react-intersection-observer

react-intersection-observer wraps the browser Intersection Observer API in React hooks, a render-prop component, and a low-level observe function. It reports when an element crosses visibility thresholds without a scroll handler, reuses native observer instances when options match, and includes test utilities for Jest and Vitest. Version 11 supports React 17 through 19 and offers useOnInView for callback-only tracking that does not update React state or cause a render on each transition.

Verdict

The default React choice when native Intersection Observer needs clean lifecycle management, state, and credible test support. Skip it when native lazy loading or CSS is enough, or when unsupported-browser fallbacks would distort product behavior.

API stability4/5The central useInView return shape and InView component have remained recognizable across releases, and version 11 still supports React 17, 18, and 19 through a broad peer range. The package also preserves both ESM and CommonJS exports. Native API additions such as scrollMargin and trackVisibility expand the options, while major releases and the newer useOnInView hook mean teams should still read migration notes before automatic upgrades.
Docs5/5The README and documentation site cover both hooks, both InView child modes, every option, unsupported-browser behavior, iframe rootMargin limits, multiple-ref composition, observer v2, low-level usage, and complete Jest and Vitest mocking. Important surprises such as the skipped initial false notification, no ref forwarding for plain children, and manual Vitest setup are stated beside the relevant API instead of hidden in issues.
Maintenance5/5Version 11.0.0 was published July 30, 2026, the repository was pushed August 6, 2026, and it is not archived. GitHub currently reports no open issues or pull requests. Current React 19 peer support, a maintained documentation application, dual-module packaging, and test helpers updated for modern runners all indicate active upkeep rather than a package surviving only through transitive installs.
Ecosystem5/5npm records 4,847,110 downloads in the latest week and GitHub reports 5,538 stars. The package supports React 17 through 19, publishes TypeScript types, exports dedicated test utilities for Jest and Vitest, and works with the platform observer rather than a proprietary runtime. Its patterns map directly onto common React tasks such as lazy rendering, impression tracking, animation triggers, and infinite scrolling.

Use it if

  • You need lazy rendering, animation triggers, infinite-list sentinels, or impression tracking tied to viewport entry
  • You want React state through useInView or a callback-only path through useOnInView without building observer lifecycle code
  • You need several targets with shared options and want observer instances reused automatically
  • You test visibility behavior in Jest or Vitest and want maintained IntersectionObserver mocks with threshold controls
Skip it if

Setup reality

Install one package and ensure React and React DOM satisfy the declared peer range of 17, 18, or 19. There are no runtime dependencies and TypeScript declarations ship with the package. The real setup issue is browser capability. If IntersectionObserver is missing, the default behavior is to throw; either load a polyfill before observers mount or set a local fallbackInView or global defaultFallbackInView value. That fallback marks every observer the same way, so true can eagerly load or count everything and false can suppress important content. Server rendering starts from initialInView, false by default, because there is no browser entry on the server. A mismatched initial guess can change rendered content after hydration, so keep essential content independent of the flag. The first false notification from the native observer is intentionally suppressed; onChange and useOnInView do not fire merely to restate the initial off-screen state. rootMargin is relative to the configured root, not always the top-level viewport, and iframe behavior follows the native API. Nested clipping containers may need scrollMargin instead. trackVisibility requires delay of at least 100 ms and has incomplete browser support. For tests, jsdom does not provide real layout: import the included test utilities and drive intersections yourself, or use Vitest Browser Mode for actual browser behavior. Non-global Vitest setups must call setupIntersectionMocking and resetIntersectionMocking manually.

Patterns

Read viewport state with useInViewtrack-visibility-state

import { useInView } from 'react-intersection-observer';

export function Section() {
  const { ref, inView, entry } = useInView({ threshold: 0.25 });

  return (
    <section ref={ref}>
      Visible: {String(inView)} at {entry?.intersectionRatio ?? 0}
    </section>
  );
}

The initial false observer notification is skipped; later entry and exit transitions update inView normally.

Track an impression without state updatestrack-without-render

import { useOnInView } from 'react-intersection-observer';

function Ad({ id }: { id: string }) {
  const ref = useOnInView(
    (inView, entry) => {
      if (inView) recordImpression(id, entry.time);
    },
    { threshold: 0.5, triggerOnce: true },
  );

  return <aside ref={ref}>Sponsored</aside>;
}

useOnInView does not update React state, and it does not accept onChange, initialInView, or fallbackInView options.

Lazy-render content after its first entrytrigger-once

function LazyChart() {
  const { ref, inView } = useInView({
    rootMargin: '200px 0px',
    triggerOnce: true,
  });

  return <div ref={ref}>{inView ? <Chart /> : <ChartSkeleton />}</div>;
}

A positive rootMargin starts loading before the target reaches the viewport; triggerOnce stops observing after entry.

Use a custom scrolling rootobserve-scroll-container

function ResultsPane() {
  const rootRef = useRef<HTMLDivElement>(null);
  const { ref, inView } = useInView({
    root: rootRef.current,
    threshold: [0, 0.5, 1],
  });

  return (
    <div ref={rootRef} style={{ overflow: 'auto', height: 320 }}>
      <div style={{ height: 800 }} />
      <div ref={ref}>{inView ? 'Visible in pane' : 'Outside pane'}</div>
    </div>
  );
}

The root is null on the first render; mounting the target after the root exists avoids observing against the wrong viewport.

Assign a local ref and observer ref togethermerge-react-refs

const localRef = useRef<HTMLDivElement | null>(null);
const { ref: inViewRef, inView } = useInView();

const setRefs = useCallback((node: HTMLDivElement | null) => {
  localRef.current = node;
  inViewRef(node);
}, [inViewRef]);

return <div ref={setRefs}>Visible: {String(inView)}</div>;

Memoize the combined callback so React does not detach and reattach both refs on every render.

Use InView with an explicit observed elementrender-prop-component

import { InView } from 'react-intersection-observer';

<InView threshold={0.5}>
  {({ ref, inView, entry }) => (
    <article ref={ref} data-ratio={entry?.intersectionRatio}>
      {inView ? 'Reading' : 'Not reading'}
    </article>
  )}
</InView>

The render-prop form is the component API to use when you need direct control of the observed element and its ref.

Let InView create a semantic wrapperrender-plain-child

<InView
  as='section'
  className='feature'
  onChange={(inView, entry) => logVisibility(inView, entry.time)}
>
  <FeatureCard />
</InView>

Plain-child mode always renders its child and does not forward a ref to the generated wrapper.

Choose behavior when IntersectionObserver is absentset-unsupported-fallback

import { defaultFallbackInView } from 'react-intersection-observer';

defaultFallbackInView(false);

Without a local or global fallback the package throws; a global value applies to every observer in that client.

Temporarily stop observing without clearing statepause-observation

const { ref, inView } = useInView({
  skip: modalOpen,
  threshold: 0.5,
});

return <div ref={ref}>Last known state: {String(inView)}</div>;

Setting skip while the element is in view preserves the current inView state rather than resetting it.

Request Intersection Observer v2 visibilitytrack-actual-visibility

const { ref, entry } = useInView({
  trackVisibility: true,
  delay: 100,
});

return <div ref={ref}>Actually visible: {String(entry?.isVisible)}</div>;

delay must be at least 100 ms; unsupported browsers fall back to reporting isVisible as true.

Drive visibility in a component testmock-intersection-test

import { render, screen } from '@testing-library/react';
import { mockIsIntersecting } from 'react-intersection-observer/test-utils';

render(<Section />);
const target = screen.getByTestId('observed');
mockIsIntersecting(target, 0.5);
expect(screen.getByText('Visible')).toBeInTheDocument();

jsdom has no real layout; the helper accepts a boolean or numeric threshold and updates the mocked observer.

Use the low-level observer and clean it upobserve-without-react

import { observe } from 'react-intersection-observer';

const unobserve = observe(
  element,
  (inView, entry) => console.log(inView, entry.intersectionRatio),
  { threshold: 0.5 },
);

// Later
unobserve();

The returned function must be called when the element is no longer needed; hooks normally perform this cleanup for you.

Alternatives

PackageRegistryPick it when
react-cool-inviewnpmChoose it when you want a hook with built-in unobserve and richer callback-oriented options
react-visibility-sensornpmChoose it only for an existing component-based codebase already committed to its older visibility model
@uidotdev/usehooksnpmChoose a broader hook collection when intersection tracking is one of many small browser helpers you need