|$ curl https://forge-ai.dev/api/markdown?path=docs/web-platform/intersection-observer
$cat docs/intersection-observer.md
updated Recently·22 min read·published
Intersection Observer
Introduction
The Intersection Observer API asynchronously observes changes in the intersection of a target element with its ancestor (or viewport). It replaces expensive scroll-event listeners for tasks like lazy loading, infinite scroll, and visibility tracking — running off the main thread.
Basic Usage
basic-observer.ts
TypeScript
| 1 | // Create an observer |
| 2 | const observer = new IntersectionObserver( |
| 3 | (entries) => { |
| 4 | entries.forEach((entry) => { |
| 5 | if (entry.isIntersecting) { |
| 6 | console.log("Element is visible:", entry.target); |
| 7 | } else { |
| 8 | console.log("Element is hidden:", entry.target); |
| 9 | } |
| 10 | }); |
| 11 | }, |
| 12 | { |
| 13 | root: null, // viewport |
| 14 | rootMargin: "0px", // no margin |
| 15 | threshold: 0, // trigger when any part is visible |
| 16 | } |
| 17 | ); |
| 18 | |
| 19 | // Observe elements |
| 20 | const elements = document.querySelectorAll(".observe-me"); |
| 21 | elements.forEach((el) => observer.observe(el)); |
| 22 | |
| 23 | // Stop observing |
| 24 | observer.unobserve(element); |
| 25 | |
| 26 | // Disconnect all |
| 27 | observer.disconnect(); |
thresholds.ts
TypeScript
| 1 | // Thresholds: trigger at specific visibility percentages |
| 2 | const observer = new IntersectionObserver(callback, { |
| 3 | threshold: [0, 0.25, 0.5, 0.75, 1], |
| 4 | // Triggers at 0%, 25%, 50%, 75%, 100% visibility |
| 5 | }); |
| 6 | |
| 7 | // Root margin: expand/contract the observation area |
| 8 | const observer = new IntersectionObserver(callback, { |
| 9 | rootMargin: "100px 0px", // 100px above and below viewport |
| 10 | // Triggers 100px before element enters viewport |
| 11 | }); |
| 12 | |
| 13 | // Negative root margin: shrink the trigger area |
| 14 | const observer = new IntersectionObserver(callback, { |
| 15 | rootMargin: "-50px 0px", // Trigger when 50px inside viewport |
| 16 | }); |
Lazy Loading
lazy-loading.ts
TypeScript
| 1 | // Lazy load images |
| 2 | const imageObserver = new IntersectionObserver( |
| 3 | (entries) => { |
| 4 | entries.forEach((entry) => { |
| 5 | if (entry.isIntersecting) { |
| 6 | const img = entry.target as HTMLImageElement; |
| 7 | img.src = img.dataset.src!; // Load actual image |
| 8 | img.classList.add("loaded"); |
| 9 | imageObserver.unobserve(img); |
| 10 | } |
| 11 | }); |
| 12 | }, |
| 13 | { rootMargin: "200px 0px" } // Start loading 200px before visible |
| 14 | ); |
| 15 | |
| 16 | document.querySelectorAll("img[data-src]").forEach((img) => { |
| 17 | imageObserver.observe(img); |
| 18 | }); |
| 19 | |
| 20 | // HTML: <img data-src="/photo.jpg" src="/placeholder.jpg" alt="..." /> |
| 21 | |
| 22 | // Lazy load components (React) |
| 23 | function LazySection({ children }: { children: React.ReactNode }) { |
| 24 | const ref = useRef<HTMLDivElement>(null); |
| 25 | const [isVisible, setIsVisible] = useState(false); |
| 26 | |
| 27 | useEffect(() => { |
| 28 | const observer = new IntersectionObserver( |
| 29 | ([entry]) => { if (entry.isIntersecting) setIsVisible(true); }, |
| 30 | { rootMargin: "200px" } |
| 31 | ); |
| 32 | if (ref.current) observer.observe(ref.current); |
| 33 | return () => observer.disconnect(); |
| 34 | }, []); |
| 35 | |
| 36 | return <div ref={ref}>{isVisible ? children : <div className="h-96" />}</div>; |
| 37 | } |
Infinite Scroll
infinite-scroll.ts
TypeScript
| 1 | // Infinite scroll with Intersection Observer |
| 2 | function useInfiniteScroll(loadMore: () => Promise<void>) { |
| 3 | const sentinelRef = useRef<HTMLDivElement>(null); |
| 4 | |
| 5 | useEffect(() => { |
| 6 | const observer = new IntersectionObserver( |
| 7 | async ([entry]) => { |
| 8 | if (entry.isIntersecting) { |
| 9 | await loadMore(); |
| 10 | } |
| 11 | }, |
| 12 | { rootMargin: "200px" } |
| 13 | ); |
| 14 | |
| 15 | if (sentinelRef.current) observer.observe(sentinelRef.current); |
| 16 | return () => observer.disconnect(); |
| 17 | }, [loadMore]); |
| 18 | |
| 19 | return sentinelRef; |
| 20 | } |
| 21 | |
| 22 | // Usage |
| 23 | function Feed() { |
| 24 | const [items, setItems] = useState<Item[]>([]); |
| 25 | const [hasMore, setHasMore] = useState(true); |
| 26 | |
| 27 | const loadMore = useCallback(async () => { |
| 28 | if (!hasMore) return; |
| 29 | const newItems = await fetchItems(items.length); |
| 30 | setItems((prev) => [...prev, ...newItems]); |
| 31 | setHasMore(newItems.length > 0); |
| 32 | }, [items.length, hasMore]); |
| 33 | |
| 34 | const sentinelRef = useInfiniteScroll(loadMore); |
| 35 | |
| 36 | return ( |
| 37 | <div> |
| 38 | {items.map((item) => <FeedItem key={item.id} item={item} />)} |
| 39 | {hasMore && <div ref={sentinelRef} className="h-10" />} |
| 40 | </div> |
| 41 | ); |
| 42 | } |
ℹ
info
Add a rootMargin of 200-400px to start fetching before the sentinel is visible. This masks network latency and creates a seamless infinite scroll experience.
$Blueprint — Engineering Documentation·Section ID: WP-IO-01·Revision: 1.0