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>
);
}
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;
}
}
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>;
}
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 loading, and keep a real button for keyboard and assistive-technology users:
import { useCallback, useEffect, useState, 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 (
<>
<ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>
<p aria-live="polite">
{loading ? "Loading more items…" : error ? "Could not load more items." : ""}
</p>
{error ? <button onClick={() => void loadMore()}>Try again</button> : null}
{hasNextPage ? (
<>
<div ref={ref} aria-hidden="true" />
<button disabled={loading} onClick={() => void loadMore()}>
{loading ? "Loading…" : "Load more"}
</button>
</>
) : null}
</>
);
}
- Atlas
- Beacon
- Current
- Drift
Start with loading: true so the sentinel cannot race the first request. After that, the skip guard blocks duplicate requests. rootMargin starts the load before the sentinel reaches the viewport, so 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 manual “Load more” button and the retry path. Scrolling should never be the only way to fetch content.