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. Building an observer is the foundation; the lazy loading pattern below applies it to a concrete performance problem, deferring image loads until the user scrolls near them and cutting initial page weight.
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 enters the viewport. By the time the user scrolls to it, the image is often already loaded. But this JavaScript-only approach leaves a gap when scripts are disabled or slow. The noscript fallback below plugs that hole with a plain <img> tag that works without any JS.
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>
Once you have images loading on demand, the same observer pattern can drive CSS animations. The code below swaps the goal — instead of swapping a src attribute, it adds a CSS class when an element enters view, triggering a fade-up transition without any scroll event listeners.
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
});
The JavaScript above registers the observer and toggles a class. The CSS below defines what that class actually does: a slide-up fade that runs once per element. Keeping the animation logic in CSS rather than JavaScript lets the browser optimize the transition on the compositor thread.
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 because 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. A sentinel element avoids triggering during normal scrolling when the last post is already visible, unlike watching the last post directly. The guard below handles the case where the network lags and the user scrolls past several sentinel heights before the first fetch resolves.
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. With infinite scroll handled, the next pattern shifts from loading content to tracking it. Sticky section monitoring uses the same observer API but for a different purpose: keeping a table of contents or navigation indicator in sync with the visible portion of the page.
Sticky section tracking
Track which section is currently in view to mark 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. The performance notes that follow cover the lifecycle rules that keep observers fast: when to disconnect them, what to avoid inside the callback, and how thresholds translate into real scroll behavior.
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. Different threshold values change when the callback fires — the examples below show how 0 and 1.0 produce opposite behaviors: any intersection vs full visibility.
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 });
A single threshold of 1.0 tells you when an element is fully in view. Multiple thresholds let you track the element across several visibility stages. The code below triggers one action at 50% visibility and a second at 90%, all from the same observer instance. This avoids creating separate observers for different visibility levels.
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] });
A practical pattern for visibility work
The cleanest Intersection Observer code usually starts with one simple question: what should happen when this element becomes visible? If the answer is “load it,” “emphasize it,” or “track it,” the observer fits well. If the answer is more complicated, break the work into a small signal from the observer and a separate function that performs the actual update. That keeps the callback readable and the surrounding code easier to maintain.
Threshold tuning follows the same principle. A threshold is not just a number. It is a way to decide when the app should react. A low threshold starts work earlier, which is helpful for images and content loading. A higher threshold waits for more of the element to appear, which can make section tracking or animation timing feel more deliberate. Pick the threshold that matches the feeling you want.
Review observer usage when a feature becomes more dynamic. If new cards, sections, or media items are added often, make sure the observer lifecycle still matches the element lifecycle. A small amount of cleanup or re-observation logic can keep the feature reliable as the page changes. That habit matters more on long-lived pages than on short demo screens.
Choosing the right observer pattern
Intersection Observer is strongest when you use it to mark transitions, not to drive every tiny scroll-related detail. A single observer can answer questions like “is this image close enough to load” or “has this section entered the active band” without forcing the browser to emit work on every scroll event. That makes the code easier to follow and keeps the main thread calmer.
The root margin and threshold values deserve careful tuning because they shape the feel of the feature. A generous margin can make images load before the user reaches them, which feels smooth. A narrow threshold can make scroll tracking more precise, which helps with sticky navigation or progress indicators. The right setting depends on whether you want early action, exact positioning, or a balance between the two.
Many apps use one observer for many targets, and that is usually the right choice when the behavior is shared. A dispatcher pattern keeps the callback short while still letting each element carry a small label or data attribute. That design keeps the observer from becoming a giant switch statement and makes it easier to add a new tracked element later without rewriting the core callback.
You also want a clear cleanup story. If an element is no longer needed, unobserve it rather than leaving it attached. That matters in long-lived pages, single-page apps, and feeds that add or remove content over time. The API is efficient, but it still works best when you are deliberate about the targets you keep around.
Finally, remember that Intersection Observer is not a replacement for every visibility check. If you need a one-off measurement or a precise layout decision, another approach may be simpler. Use the observer when the browser can watch for you over time, and use direct measurement when you need an immediate answer right now.
See Also
- /guides/javascript-webgl-basics/, where Intersection Observer can detect canvas visibility for performance
- /guides/javascript-web-workers/, to offload heavy processing from the main thread so scroll and intersection events stay smooth
- /guides/javascript-webrtc/, a real-time communication API useful alongside Intersection Observer for chat or feed features that load on scroll