Mastering Intersection Observer: Infinite Scroll, Lazy Media, and Reading Progress Bars
Say goodbye to janky window scroll listeners. Learn how to harness the native browser IntersectionObserver API for zero-lag infinite scrolling, lazy media pre-fetching, and scrollspy navigation.
The Death of window.addEventListener('scroll')
In the early days of web development, implementing infinite scroll or lazy image loading required binding event listeners to window.onscroll. On every single pixel of movement, JavaScript executed element.getBoundingClientRect() calculations on the browser main thread.
This pattern is a primary culprit of scrolling jank and dropped frames: calling getBoundingClientRect() forces synchronous layout reflows, causing the browser to freeze frame rendering while recalculating page geometry.
The native Intersection Observer API completely eliminates main thread scroll overhead. It offloads intersection calculations entirely to the browser's compositor thread, notifying JavaScript asynchronously only when an element enters or exits a defined visibility boundary.
In this guide, we master IntersectionObserver patterns for infinite scrolling, pre-fetching thresholds, and memory disposal.
The Sentinel Element Pattern for Infinite Scrolling
Instead of tracking where the user's scrollbar sits, place a tiny, invisible 1px 'sentinel' element at the bottom of your card feed. When the sentinel enters the viewport, trigger the fetchNextPage() query.
Use the rootMargin property (e.g., rootMargin: '400px') to trigger the network fetch 400 pixels before the user even reaches the bottom, creating a seamless, buffer-free infinite scroll.
import { useEffect, useRef } from "react";
export function useInfiniteScrollSentinel({
hasNextPage,
isFetching,
fetchNextPage,
}: {
hasNextPage: boolean;
isFetching: boolean;
fetchNextPage: () => void;
}) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && !isFetching) {
fetchNextPage();
}
},
{
// Pre-fetch 500px before user reaches the bottom
rootMargin: "0px 0px 500px 0px",
threshold: 0,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetching, fetchNextPage]);
return sentinelRef;
}Lazy Loading High-Resolution Images with Threshold Intersections
While modern browsers support native loading='lazy' on <img> tags, IntersectionObserver is essential when orchestrating background images, HTML5 video auto-play triggers, or low-quality image placeholder (LQIP) blur cross-fades.
Once the high-res image has loaded into memory, call observer.unobserve(element) to free up browser tracking resources.
import { useState, useEffect, useRef } from "react";
export function LazyImage({ src, alt, placeholderSrc }: { src: string; alt: string; placeholderSrc: string }) {
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const target = imgRef.current;
if (!target) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Preload high-res image
const img = new Image();
img.src = src;
img.onload = () => setLoadedSrc(src);
observer.unobserve(target);
}
},
{ rootMargin: "200px" }
);
observer.observe(target);
return () => observer.disconnect();
}, [src]);
return (
<img
ref={imgRef}
src={loadedSrc || placeholderSrc}
alt={alt}
className={`transition-all duration-500 ${loadedSrc ? "blur-0" : "blur-sm"}`}
/>
);
}Dynamic Table of Contents Scrollspy Navigation
For long-form documentation and blog articles, highlight the active reading section in the sidebar as the user scrolls through headings.
Configure an IntersectionObserver that observes all <h2> and <h3> elements with a negative top rootMargin to track which section is currently occupying the middle reading third of the viewport.
import { useState, useEffect } from "react";
export function useScrollspy(headingIds: string[]): string {
const [activeId, setActiveId] = useState<string>("");
useEffect(() => {
const elements = headingIds
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{
// Focus on middle 40% reading window
rootMargin: "-20% 0px -60% 0px",
threshold: 0,
}
);
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [headingIds]);
return activeId;
}Building an Animated Reading Progress Bar Without Scroll Listeners
Reading progress bars show users their completion percentage as they read long-form technical guides. Instead of calculating scroll positions on every scroll event tick, anchor sentinels at 10% interval markers throughout article paragraphs.
As each interval sentinel intersects the viewport, update an SVG progress ring or top indicator bar with smooth CSS transitions, maintaining a lightweight 0ms scripting profile on mobile processors.
export function ReadingProgressBar({ progressPercent }: { progressPercent: number }) {
return (
<div className="fixed top-0 left-0 right-0 h-1 bg-surface-2 z-50">
<div
className="h-full bg-signal transition-all duration-300 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
);
}Auto-Pausing Off-Screen HTML5 Videos to Conserve CPU and Battery
Autoplaying background videos create massive battery drain and CPU contention if they continue decoding video frames while scrolled off-screen.
Use an IntersectionObserver targeting video elements: when intersectionRatio falls to 0, call video.pause(); when it re-enters the viewport (intersectionRatio > 0.5), resume playback with video.play().
export function initSmartVideoPlayer(videoElement: HTMLVideoElement) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
videoElement.play().catch(() => {});
} else {
videoElement.pause();
}
},
{ threshold: [0, 0.5] }
);
observer.observe(videoElement);
return () => observer.disconnect();
}Memory Management and Observer Cleanup
Always call observer.disconnect() in your component unmount lifecycle (e.g., in the return function of useEffect). Neglecting to disconnect observers causes memory leaks as detached DOM nodes remain retained in memory.
What does the threshold property in IntersectionObserver configure?
threshold specifies what percentage of the target element must be visible before triggering the callback (0 = as soon as 1 pixel enters; 1.0 = entire element is visible).
Can IntersectionObserver track elements inside overflow: auto containers?
Yes! By passing the scrollable parent container element as the root property, the observer tracks intersections relative to that container instead of the document viewport.
What is rootMargin and how is it formatted?
rootMargin works like CSS margins (top, right, bottom, left). A value like '0px 0px 400px 0px' expands the intersection detection zone 400px below the bottom edge.
Does IntersectionObserver work with CSS transform animations?
Yes. IntersectionObserver computes geometric bounds accurately even if elements are scaled or translated via GPU transforms.
How does IntersectionObserver impact battery life on mobile devices?
Because intersection checks run asynchronously in native browser C++ code off the main thread, it consumes up to 90% less CPU and battery power than legacy scroll event listeners.
Can IntersectionObserver observe elements inside cross-origin iframes?
Yes, but for security, cross-origin iframes only receive intersection information if the target is visible in the top-level viewport, without exposing exact coordinate offsets.