--- title: Recipes description: Complete visibility-aware React patterns built with react-intersection-observer. --- Each recipe starts with the problem it solves, shows a complete component, and calls out the boundary that is easiest to miss. 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 the `loading="lazy"` attribute to ordinary image elements. It lets the browser decide when to defer image loading without any observer code. ::: Use the observer when you need an explicit preload margin, a custom placeholder, or a component-level loading transition. ```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 (
{inView ? ( {alt} ) : ( ); } ``` Reserve the image's space with dimensions or an aspect ratio. Otherwise the image can shift the page after it loads, and the observer may repeatedly encounter 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 (
{children}
); } ``` ```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 baseline remains visible until the browser can enhance it, and `fallbackInView: true` keeps it visible when observation is unavailable. Do not make the animation the only way to discover important content; reduced-motion users see it immediately. ## 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
Tracked content
; } ``` The package ignores the initial `false` notification, so this callback records the first accepted enter transition. Keep the identifier stable and decide whether `triggerOnce` matches your analytics definition of an impression. An intersection does not prove that content was meaningfully viewed: add a minimum-duration rule when your definition requires time in view, and evaluate [Observer v2](/guides/intersection-observer-v2) when covered or visually compromised content matters. ## Infinite scrolling Observe a sentinel at the end of a list, guard requests while loading, and keep an explicit fallback action for keyboard and assistive-technology users: ```tsx import { useCallback, useEffect, useState, type Key, type ReactNode } from "react"; import { useInView } from "react-intersection-observer"; type Page = { items: T[]; hasNextPage: boolean }; export function InfiniteList({ getKey, loadPage, renderItem, }: { getKey: (item: T) => Key; loadPage: (skip: number) => Promise>; renderItem: (item: T) => ReactNode; }) { const [items, setItems] = useState([]); const [hasNextPage, setHasNextPage] = useState(true); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const loadMore = useCallback(async () => { if (loading || !hasNextPage) return; setLoading(true); setError(null); try { const page = await loadPage(items.length); setItems((current) => [...current, ...page.items]); setHasNextPage(page.hasNextPage); } catch (cause) { setError(cause instanceof Error ? cause : new Error("Unable to load")); } finally { setLoading(false); } }, [hasNextPage, items.length, loadPage, loading]); useEffect(() => { let cancelled = false; setLoading(true); void loadPage(0) .then((page) => { if (cancelled) return; setItems(page.items); setHasNextPage(page.hasNextPage); }) .catch((cause) => { if (!cancelled) { setError(cause instanceof Error ? cause : new Error("Unable to load")); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [loadPage]); const { ref } = useInView({ rootMargin: "400px 0px", skip: loading || !hasNextPage || error !== null, onChange: (inView) => { if (inView) void loadMore(); }, }); return ( <>
    {items.map((item) =>
  • {renderItem(item)}
  • )}

{loading ? "Loading more items…" : error ? "Could not load more items." : ""}

{error ? : null} {hasNextPage ? ( <>