// apis · Web Platform Advent #10
The IntersectionObserver API: lazy-loading and scroll effects
Use IntersectionObserver to lazy-load images, reveal elements on scroll and build infinite scroll — with threshold and rootMargin explained, plus runnable examples.
The IntersectionObserver API tells you when an element enters or leaves the viewport — without you wiring up a scroll listener and computing positions on every frame. The browser does the watching asynchronously and off the main thread, so it stays smooth even with hundreds of targets. Every current browser supports it.
Why not just listen to scroll?
A scroll handler fires constantly and forces you to call getBoundingClientRect() to know where things are — that reads layout and can cause jank. IntersectionObserver flips the model: you register the elements you care about, and the browser notifies you only when their visibility actually changes.
The basic pattern
Create an observer with a callback, then observe() one or more elements. The callback receives an array of entry objects; the key property is entry.isIntersecting:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log('Now visible:', entry.target);
}
}
});
document.querySelectorAll('.watch').forEach((el) => observer.observe(el)); Lazy-loading images
A classic use: only load an image once it is about to scroll into view. Store the real URL in data-src, swap it in when the element intersects, then stop observing it:
const lazyImages = document.querySelectorAll('img[data-src]');
const imgObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // load once, then stop watching
}
}, { rootMargin: '200px' });
lazyImages.forEach((img) => imgObserver.observe(img)); Note: native lazy-loading via <img loading="lazy"> covers most cases now. Reach for IntersectionObserver when you need custom logic — placeholders, fade-ins, or loading non-image resources.
Reveal elements on scroll
Add a CSS class when an element first becomes visible, so it can animate in. Use the threshold option to decide how much of the element must be visible before the callback fires:
const revealObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
}
}, { threshold: 0.2 }); // fire when 20% is on screen
document.querySelectorAll('.reveal').forEach((el) => revealObserver.observe(el)); Infinite scroll
Place an empty sentinel element after your list. When it enters the viewport, fetch and append the next page, then keep observing for the page after:
const sentinel = document.querySelector('#sentinel');
let page = 1;
const loader = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
const items = await fetchPage(page++);
appendItems(items);
});
loader.observe(sentinel); The options: threshold and rootMargin
Two options shape when the callback fires:
threshold— a number (or array) from 0 to 1.0fires the moment one pixel is visible;1fires only when the whole element is visible. An array like[0, 0.5, 1]fires at each step, handy for scroll-progress effects.rootMargin— a CSS-style margin that grows or shrinks the area used for the test.'200px'triggers 200px before the element reaches the viewport — ideal for pre-loading.root— the element to use as the viewport. Defaults to the browser viewport; set it to a scrollable container to watch inside that instead.
Cleaning up
Stop watching a single element with observer.unobserve(el), or tear everything down with observer.disconnect() — important in single-page apps when a view unmounts, to avoid leaks.
Quick reference
| Goal | Key option / call |
|---|---|
| Detect visibility | entry.isIntersecting |
| Pre-load early | rootMargin: '200px' |
| Wait until X% visible | threshold: 0.5 |
| Watch inside a container | root: containerEl |
| Stop watching one | observer.unobserve(el) |
| Tear down all | observer.disconnect() |
IntersectionObserver replaces a whole category of fragile scroll math with a small, declarative API. Lazy images, scroll reveals and infinite lists all reduce to the same pattern: observe, react to isIntersecting, and unobserve when you're done.