Skip to content
Esc
navigateopen⌘Jpreview
On this page

Recipes

Complete visibility-aware React patterns built with react-intersection-observer.

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

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>
  );
}
Preload before the image arrivesThe observer starts work 72px before the image enters the reading area.
Scroll down to release the image request
QueuedWaiting inside the preload margin
Product update · 3 min read
WaitingWaiting for the preload margin

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;
  }
}
Reveal without hiding the content sourceThe content stays in the document; its presentation changes after 70% enters the reading area.
Scroll until most of the section is in view
Approaching the thresholdThe section is ready to read.A CSS transition can follow observer state without removing the content.
WaitingScroll until 70% is visible

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>;
}
Record one meaningful impressionThe event is recorded once when at least half of the card is visible.
Reach half of this card to record the event
Recommended readingHow to make visibility events usefulThis card has a stable content identifier and a one-time event policy.
WaitingWaiting for a meaningful view

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 keep a real button for keyboard and assistive-technology users:

import { useState, useTransition, type Key, type ReactNode } from "react";
import { useInView } from "react-intersection-observer";

type Page<T> = { items: T[]; hasNextPage: boolean };

export function InfiniteList<T>({
  getKey,
  loadPage,
  renderItem,
}: {
  getKey: (item: T) => Key;
  loadPage: (skip: number) => Promise<Page<T>>;
  renderItem: (item: T) => ReactNode;
}) {
  const [items, setItems] = useState<T[]>([]);
  const [hasNextPage, setHasNextPage] = useState(true);
  const [failed, setFailed] = useState(false);
  const [isPending, startTransition] = useTransition();

  function loadMore() {
    if (isPending || !hasNextPage) return;

    setFailed(false);
    startTransition(async () => {
      try {
        const page = await loadPage(items.length);
        setItems((current) => [...current, ...page.items]);
        setHasNextPage(page.hasNextPage);
      } catch {
        setFailed(true);
      }
    });
  }

  const { ref } = useInView({
    rootMargin: "400px 0px",
    skip: isPending || failed || !hasNextPage,
    onChange: (inView) => {
      if (inView) loadMore();
    },
  });

  return (
    <>
      <ul>
        {items.map((item) => (
          <li key={getKey(item)}>{renderItem(item)}</li>
        ))}
      </ul>

      <p aria-live="polite">
        {isPending ? "Loading more items…" : null}
        {failed ? "Could not load more items." : null}
      </p>

      {hasNextPage ? (
        <>
          <div ref={ref} aria-hidden="true" />
          <button disabled={isPending} onClick={loadMore}>
            {failed ? "Try again" : "Load more"}
          </button>
        </>
      ) : null}
    </>
  );
}
Load the next page near the list endThe sentinel preloads one page, and the button remains as the explicit fallback.
  1. Atlas
  2. Beacon
  3. Current
  4. Drift
Scroll toward this sentinel
WaitingSentinel waits 56px before the list end

There is no effect here. An empty list puts the sentinel in the viewport, so the observer requests the first page the same way it requests every page after it. Add an effect for that first page and you get a second copy of the fetch and the error handling, racing the observer for the same request.

useTransition tracks the pending state for you. React sets isPending when startTransition runs and clears it once the awaited work settles, so there is no setLoading(false) to repeat on every exit path. startTransition also awaits the async function you give it, so there is no unawaited promise. The append becomes a transition as well, which lets a list of hundreds of rows re-render without blocking a click.

A hand-rolled loading boolean breaks in one specific case. When loadPage resolves from a warm cache, React can batch the update that sets the flag with the one that clears it. skip never changes, the observer never starts again, and the list stops after one page. isPending always produces a render.

The transition has no error channel, so the try/catch stays. Drop it and the throw reaches the nearest error boundary, which swaps the list for a fallback and takes every page already loaded with it. Catching here keeps the rows on screen and turns the button into a retry.

skip does two jobs. It stops a second request while one is in flight. It also gets the sentinel watching again, because the hook drops the observer and creates a new one each time skip flips back. So a page too short to push the sentinel out of view keeps loading until the viewport fills.

rootMargin starts the request before the sentinel reaches the viewport. Tune the 400px value to your network and item size. This example sends items.length as the next page’s skip; use whatever pagination your API expects.

Keep the button. It is the retry after a failure, and it is the only route for keyboard and assistive-technology users. Scrolling should never be the only way to fetch content.

Was this page helpful?