---
title: Recipes
description: 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](/api/core-apis) and [Configuration](/api/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>
  );
}
```

<ObserverDemo recipe="lazy-image" />

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;
  }
}
```

<ObserverDemo recipe="reveal" />

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>;
}
```

<ObserverDemo recipe="impression" />

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](/guides/intersection-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:

```tsx
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}
    </>
  );
}
```

<ObserverDemo recipe="infinite-list" />

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