Intersection Observer Patterns
The Intersection Observer API detects when elements enter or leave the viewport. Rather than polling with scroll events, you register a callback that fires asynchronously when intersection changes. This makes it fast enough to use on every scroll without killing your frame rate.
The basic usage is in the browser tutorial. This guide covers the patterns that come up in real applications: lazy loading, scroll-triggered animations, infinite scroll, and sticky section tracking.
How the Observer Works
You create an observer with a callback and options, then tell it which elements to watch:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// entry.isIntersecting is true when the element crossed the threshold
});
}, {
root: null, // null = the viewport
rootMargin: '0px', // expand/shrink the root bounds
threshold: 0.1 // fire when 10% of the element is visible
});
observer.observe(element);
The callback receives an array of IntersectionObserverEntry objects. Each entry tells you whether the element is intersecting and by how much.
Lazy Loading Images
The most common use case. Load images only when they scroll into view:
const imgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
const src = img.dataset.src; // store real URL in data attribute
img.src = src;
img.removeAttribute('data-src');
imgObserver.unobserve(img); // stop watching once loaded
});
}, {
rootMargin: '200px' // start loading 200px before the image enters the viewport
});
document.querySelectorAll('img[data-src]').forEach(img => imgObserver.observe(img));
With rootMargin: '200px', the browser starts loading the image before it actually enters the viewport, so by the time the user scrolls to it, it is often already loaded.
The noscript Fallback
If JavaScript fails or is slow, users see broken images. Put a real fallback image in a <noscript> tag:
<img data-src="photo-high-res.jpg" src="photo-low-res.jpg" alt="..." />
<noscript>
<img src="photo-high-res.jpg" alt="..." />
</noscript>
Scroll-Triggered Animations
Trigger a CSS animation when an element enters the viewport:
const animatedObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
animatedObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.2,
rootMargin: '-50px' // trigger slightly before the element reaches the center
});
CSS:
.fade-up {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.fade-up.is-visible {
opacity: 1;
transform: translateY(0);
}
The rootMargin: '-50px' triggers the animation when the element is 50px above the center of the viewport rather than at the very edge. This feels more natural — the user sees the animation start as the element enters from below.
Infinite Scroll
Load more content when the user nears the bottom of the list:
const loadMoreObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMorePosts();
}
});
}, {
rootMargin: '300px' // start loading before the sentinel is visible
});
const sentinel = document.getElementById('scroll-sentinel');
loadMoreObserver.observe(sentinel);
The sentinel is a thin element at the bottom of the page. When it approaches the viewport, loadMorePosts() fires and appends new items. Using a sentinel element rather than watching the last post directly avoids triggering during normal scrolling when the last post is already visible.
Debouncing the Load
If the network is slow, the user might scroll past several sentinel heights before the first load completes. Guard against double-firing:
let isLoading = false;
async function loadMorePosts() {
if (isLoading) return;
isLoading = true;
try {
const posts = await fetchPosts(page);
appendPosts(posts);
page++;
} finally {
isLoading = false;
}
}
This pattern does not prevent the observer from firing — it just prevents the load from running twice simultaneously.
Sticky Section Tracking
Track which section is currently in view to highlight a table of contents or progress indicator:
const headerObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.id;
if (entry.isIntersecting) {
setActiveLink(id); // highlight the nav item for this section
}
});
}, {
rootMargin: '-20% 0px -60% 0px' // only trigger when section is in the middle
});
document.querySelectorAll('section[id]').forEach(section => {
headerObserver.observe(section);
});
With rootMargin: '-20% 0px -60% 0px', a section triggers only when it sits in the middle 20% of the viewport — the top 20% and bottom 60% are excluded. This prevents flickering when a section barely grazes the threshold while scrolling through.
Reusing a Single Observer
If you have many elements that all need the same logic, use one observer with a type flag:
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const type = entry.target.dataset.observeType;
if (type === 'image') {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
} else if (type === 'video') {
entry.target.src = entry.target.dataset.src;
entry.target.load();
} else if (type === 'component') {
entry.target.dataset.loaded = 'true';
}
lazyObserver.unobserve(entry.target);
});
}, { rootMargin: '300px' });
One observer is more efficient than dozens of individual observers, and the dispatcher pattern keeps the observer itself simple.
Performance Notes
Always disconnect observers you no longer need. An observer that watches elements no longer in the DOM leaks memory:
// When removing elements from the DOM:
const someSection = document.getElementById('old-section');
lazyObserver.unobserve(someSection);
someSection.remove();
Avoid reading layout properties inside the callback. Properties like getBoundingClientRect(), offsetWidth, or offsetHeight force synchronous layout, which defeats the whole point of the async API. Keep the callback fast.
Threshold 0 fires on any intersection. If you only care when an element is fully visible, use threshold: 1.0:
const fullObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// element is fully in view
}
});
}, { threshold: 1.0 });
Multiple thresholds let you trigger at multiple visibility percentages without re-observing:
const multiObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio >= 0.5) {
// more than half visible
}
if (entry.intersectionRatio >= 0.9) {
// almost fully visible
}
});
}, { threshold: [0.5, 0.9] });
See Also
- /guides/javascript-webgl-basics/ — GPU-accelerated rendering where Intersection Observer can detect canvas visibility for performance
- /guides/javascript-web-workers/ — offload heavy processing from the main thread so scroll and intersection events stay smooth
- /guides/javascript-webrtc/ — real-time communication API, useful alongside Intersection Observer for chat or feed features that load on scroll