Revealing a card as it reaches the screen, loading an image at the last moment, counting the sections actually read: three needs that come up on almost every long page.
The historical method was to listen to scrolling and measure positions on every pixel traveled. Expensive, imprecise, and responsible for plenty of stutter. The browser can run that calculation itself.
Definition
IntersectionObserver watches the overlap between an element and a reference area, the window by default. It calls a callback function when that element enters the area or leaves it, with no scroll listener involved.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
}
}, { threshold: 0.25 });
document.querySelectorAll(".card").forEach((card) => {
observer.observe(card);
});A single observer tracks as many elements as you hand it. The call to unobserve drops the card from the watch list once revealed: an entrance animation has no business playing twice.
The three options
The second argument of the constructor tunes the sensitivity. The table lays out its three options across three columns: the option, its default value, and what it controls.
| Option | Default | What it controls |
|---|---|---|
root | null | The reference area. Left empty, it is the browser window |
rootMargin | "0px" | A margin that grows or shrinks that area |
threshold | 0 | How much of the element must show to trigger the call |
A margin of "200px 0px" fires the call two hundred pixels before the element actually arrives, which gives an image time to load. A threshold also accepts an array, [0, 0.5, 1], to be notified at several steps.
What each entry holds
The callback receives an array, never a single element: several elements can cross the boundary within the same frame.
- isIntersecting: a Boolean telling whether the element overlaps the area.
- target: the element in question, essential since the array mixes several of them.
- intersectionRatio: the visible share, from 0 to 1.
- boundingClientRect: the measured dimensions, already computed by the browser.
Thresholds compare visible area, not height. An element taller than the window will never reach a threshold of 1, and the call you expected will simply never come.
Frequently asked questions
Why does the callback fire right after loading?
Because the observer reports the initial state of every element handed to it, including those far off screen. Those first calls carry an isIntersecting set to false, which is why testing that property matters rather than assuming a call means an element appeared.
Is it still the right tool for lazy-loading images?
Not really: the loading="lazy" attribute covers images and embedded frames in one line of HTML, with no script at all. The observer keeps its value for entrance animations, infinite scrolling, and measuring which sections were genuinely seen.
How do I stop watching?
unobserve drops one element, disconnect stops everything at once. The second call is essential when a component leaves the page: without it, the observer keeps a reference to removed elements, which can then never be released from memory.