Intersection Observer API
What is the Intersection Observer API?
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with the top-level document viewport. In simpler terms, it tells you when an element enters or leaves the visible area of the page. Before this API existed, developers relied on scroll event listeners combined with getBoundingClientRect() to detect element visibility -- a pattern that was inefficient, error-prone, and caused significant performance problems because it ran on the main thread during every scroll event.
The Intersection Observer API solves these problems by moving visibility detection off the main thread. The browser handles the observation internally and only notifies your JavaScript code when a meaningful intersection change occurs. This is fundamentally more efficient than polling element positions on every scroll tick. The API is supported in all modern browsers and has become the standard approach for implementing lazy loading, infinite scroll, scroll-triggered animations, and visibility tracking.
Common use cases include: lazy loading images and videos so they only load when scrolled into view, implementing infinite scroll pagination that loads more content as the user approaches the bottom, triggering CSS animations when elements enter the viewport, detecting when a sticky header should activate, tracking which sections of a page are currently visible for navigation highlighting, and measuring ad viewability for analytics.
The IntersectionObserver Constructor
You create an Intersection Observer by calling the IntersectionObserver constructor with two arguments: a callback function that fires when intersection changes occur, and an optional configuration object that controls how the observation works.
Creating an Intersection Observer
// The callback receives two arguments:
// 1. entries: an array of IntersectionObserverEntry objects
// 2. observer: a reference to the observer itself
function handleIntersection(entries, observer) {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is visible:', entry.target);
console.log('Visibility ratio:', entry.intersectionRatio);
} else {
console.log('Element is not visible:', entry.target);
}
});
}
// Configuration options
const options = {
root: null, // viewport as the root
rootMargin: '0px', // no margin around the root
threshold: 0 // trigger as soon as even 1 pixel is visible
};
// Create the observer
const observer = new IntersectionObserver(handleIntersection, options);
// Start observing an element
const targetElement = document.querySelector('.my-element');
observer.observe(targetElement);
Understanding the Options Object
The options object controls the behavior of the observer with three properties: root, rootMargin, and threshold. Each one shapes how and when the observer triggers its callback.
The root Option
The root property defines the element that serves as the viewport for checking intersection. When set to null (the default), the browser viewport is used as the root. You can also set it to any scrollable ancestor element, which is useful for detecting visibility within a scrollable container like a sidebar, a modal with overflow scroll, or a chat window.
Using a Custom Root Element
// Observe elements within a scrollable container
const scrollContainer = document.querySelector('.scroll-container');
const observer = new IntersectionObserver(callback, {
root: scrollContainer, // use this container instead of the viewport
rootMargin: '0px',
threshold: 0.5 // 50% visible within the container
});
// Observe items inside the scrollable container
scrollContainer.querySelectorAll('.list-item').forEach(item => {
observer.observe(item);
});
The rootMargin Option
The rootMargin property grows or shrinks the root element's bounding box before computing intersections. It uses CSS margin syntax: "top right bottom left". Positive values expand the root area (triggering intersection before the element is actually visible), while negative values shrink it (requiring the element to be further inside the viewport before triggering). This is extremely useful for preloading content before it scrolls into view.
Using rootMargin for Preloading
// Start loading images 200px before they enter the viewport
const lazyImageObserver = new IntersectionObserver(loadImage, {
root: null,
rootMargin: '200px 0px', // 200px above and below the viewport
threshold: 0
});
// The image will start loading when it is within 200px
// of entering the viewport, giving it a head start
document.querySelectorAll('img[data-src]').forEach(img => {
lazyImageObserver.observe(img);
});
// Negative rootMargin: require element to be 100px inside viewport
const deepVisibilityObserver = new IntersectionObserver(callback, {
rootMargin: '-100px 0px', // shrink root by 100px on top and bottom
threshold: 0
});
// Element must be at least 100px inside the viewport to trigger
The threshold Option
The threshold property defines at what percentage of the target element's visibility the callback should fire. It accepts either a single number or an array of numbers, each between 0 and 1. A threshold of 0 means the callback fires as soon as even one pixel of the element is visible. A threshold of 1.0 means the callback fires only when the entire element is visible. An array of thresholds fires the callback at each specified visibility level.
Working with Thresholds
// Single threshold: fire when 50% visible
const halfVisibleObserver = new IntersectionObserver(callback, {
threshold: 0.5
});
// Multiple thresholds: fire at 0%, 25%, 50%, 75%, and 100%
const granularObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
const element = entry.target;
// Fade in proportionally to visibility
element.style.opacity = ratio;
// Add class when more than 50% visible
if (ratio > 0.5) {
element.classList.add('mostly-visible');
} else {
element.classList.remove('mostly-visible');
}
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1.0]
});
// Generate thresholds at every 10% increment
const thresholds = Array.from({ length: 11 }, (_, i) => i / 10);
// Result: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
const smoothObserver = new IntersectionObserver(callback, {
threshold: thresholds
});
threshold: 0. For animations that progress based on visibility, use an array of thresholds. Avoid using too many thresholds unnecessarily, as each crossing triggers the callback.IntersectionObserverEntry Properties
Every time the callback fires, it receives an array of IntersectionObserverEntry objects. Each entry contains detailed information about the intersection state of one observed element. Understanding these properties is essential for building sophisticated intersection-based features.
Examining Entry Properties
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Boolean: is the element currently intersecting with the root?
console.log('isIntersecting:', entry.isIntersecting);
// Number (0 to 1): what fraction of the element is visible?
console.log('intersectionRatio:', entry.intersectionRatio);
// DOMRectReadOnly: the target element's bounding rectangle
console.log('boundingClientRect:', entry.boundingClientRect);
// Properties: top, right, bottom, left, width, height, x, y
// DOMRectReadOnly: the visible portion of the target
console.log('intersectionRect:', entry.intersectionRect);
// DOMRectReadOnly: the root element's bounding rectangle
console.log('rootBounds:', entry.rootBounds);
// Element: the observed target element
console.log('target:', entry.target);
// DOMHighResTimeStamp: when the intersection change occurred
console.log('time:', entry.time);
});
}, { threshold: [0, 0.25, 0.5, 0.75, 1.0] });
The most commonly used properties are isIntersecting (a simple boolean check), intersectionRatio (for progressive effects), and target (to identify which element triggered the callback). The boundingClientRect property is useful when you need position information without calling getBoundingClientRect() separately, which would force a layout recalculation.
Observing and Unobserving Elements
The observer instance provides methods to start and stop watching elements. Proper cleanup is important for preventing memory leaks, especially in single-page applications where elements are dynamically added and removed from the DOM.
Observer Lifecycle Methods
const observer = new IntersectionObserver(callback, options);
// Start observing a single element
const element = document.querySelector('.target');
observer.observe(element);
// Observe multiple elements with the same observer
document.querySelectorAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
// Stop observing a specific element
observer.unobserve(element);
// Stop observing ALL elements and clean up
observer.disconnect();
// Common pattern: unobserve after first intersection (one-time trigger)
const oneTimeObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Perform the action
entry.target.classList.add('animated');
// Stop watching this element -- it only needs to trigger once
obs.unobserve(entry.target);
}
});
});
// Get all currently observed elements (returns IntersectionObserverEntry[])
const currentEntries = observer.takeRecords();
observer.disconnect() or observer.unobserve() when you no longer need the observer. In single-page applications, failing to disconnect observers when components unmount causes memory leaks because the observer maintains references to DOM elements that may no longer exist in the document.Lazy Loading Images
Lazy loading images is the most common and impactful use case for the Intersection Observer. Instead of loading all images when the page loads (which wastes bandwidth and slows initial render), you load images only when they are about to scroll into view. This can dramatically improve initial page load time, especially on image-heavy pages.
Complete Lazy Loading Implementation
// HTML structure for lazy images:
// <img data-src="photo.jpg" data-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
// alt="Description" class="lazy" width="800" height="600">
class LazyImageLoader {
constructor(options = {}) {
this.loadedCount = 0;
this.observedCount = 0;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: options.root || null,
rootMargin: options.rootMargin || '200px 0px',
threshold: 0
}
);
}
init(selector = 'img[data-src]') {
const images = document.querySelectorAll(selector);
this.observedCount = images.length;
images.forEach(img => {
// Add a placeholder style while loading
img.classList.add('lazy--pending');
this.observer.observe(img);
});
console.log(`Lazy loader initialized: ${this.observedCount} images`);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
this.loadImage(img);
this.observer.unobserve(img);
});
}
loadImage(img) {
// Set up load and error handlers
img.addEventListener('load', () => {
img.classList.remove('lazy--pending');
img.classList.add('lazy--loaded');
this.loadedCount++;
}, { once: true });
img.addEventListener('error', () => {
img.classList.remove('lazy--pending');
img.classList.add('lazy--error');
console.warn('Failed to load image:', img.dataset.src);
}, { once: true });
// Set the real source attributes
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
delete img.dataset.srcset;
}
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
}
destroy() {
this.observer.disconnect();
}
}
// Initialize lazy loading
const lazyLoader = new LazyImageLoader({
rootMargin: '300px 0px' // Start loading 300px before visible
});
lazyLoader.init();
// CSS for lazy loading states:
// .lazy--pending { background: #f0f0f0; filter: blur(5px); }
// .lazy--loaded { animation: fadeIn 0.3s ease-in; }
// .lazy--error { background: #fee; }
<img loading="lazy">. However, the Intersection Observer approach gives you more control over the rootMargin (how early to start loading), loading animations, error handling, and tracking metrics. You can combine both: use native lazy loading as a baseline and enhance with Intersection Observer for advanced features.Infinite Scroll
Infinite scroll loads additional content automatically as the user scrolls toward the bottom of the page. Instead of listening to scroll events and calculating distances, you observe a sentinel element placed at the bottom of the content list. When the sentinel enters the viewport, you fetch the next page of content.
Infinite Scroll with Intersection Observer
class InfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.endpoint = options.endpoint;
this.page = 1;
this.isLoading = false;
this.hasMore = true;
// Create a sentinel element at the bottom
this.sentinel = document.createElement('div');
this.sentinel.className = 'infinite-scroll-sentinel';
this.sentinel.setAttribute('aria-hidden', 'true');
this.container.appendChild(this.sentinel);
// Observe the sentinel
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '400px 0px', // Load 400px before reaching bottom
threshold: 0
}
);
this.observer.observe(this.sentinel);
}
handleIntersection(entries) {
const sentinelEntry = entries[0];
if (sentinelEntry.isIntersecting && !this.isLoading && this.hasMore) {
this.loadMore();
}
}
async loadMore() {
this.isLoading = true;
this.showLoadingIndicator();
try {
const response = await fetch(
`${this.endpoint}?page=${this.page + 1}`
);
if (!response.ok) throw new Error('Failed to load');
const data = await response.json();
if (data.items.length === 0) {
this.hasMore = false;
this.showEndMessage();
this.observer.disconnect();
return;
}
this.page++;
this.appendItems(data.items);
this.hasMore = data.hasMore;
if (!this.hasMore) {
this.showEndMessage();
this.observer.disconnect();
}
} catch (error) {
this.showError('Failed to load more items. Tap to retry.');
} finally {
this.isLoading = false;
this.hideLoadingIndicator();
}
}
appendItems(items) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const element = this.createItemElement(item);
fragment.appendChild(element);
});
// Insert items before the sentinel
this.container.insertBefore(fragment, this.sentinel);
}
createItemElement(item) {
const article = document.createElement('article');
article.className = 'feed-item';
article.innerHTML = `
<h3>${item.title}</h3>
<p>${item.excerpt}</p>
<time datetime="${item.date}">${item.formattedDate}</time>
`;
return article;
}
showLoadingIndicator() {
this.sentinel.innerHTML = '<div class="spinner">Loading...</div>';
}
hideLoadingIndicator() {
this.sentinel.innerHTML = '';
}
showEndMessage() {
this.sentinel.innerHTML = '<p class="end-message">You have reached the end.</p>';
}
showError(message) {
this.sentinel.innerHTML = `<button class="retry-btn">${message}</button>`;
this.sentinel.querySelector('.retry-btn').addEventListener('click', () => {
this.loadMore();
}, { once: true });
}
destroy() {
this.observer.disconnect();
this.sentinel.remove();
}
}
// Initialize infinite scroll
const feed = new InfiniteScroll(
document.querySelector('.feed-container'),
{ endpoint: '/api/articles' }
);
Scroll-Triggered Animations
One of the most visually impactful uses of Intersection Observer is triggering CSS animations when elements scroll into view. This creates a dynamic, engaging experience where content appears to come alive as the user scrolls. The key pattern is adding a CSS class when the element becomes visible, with CSS transitions or animations handling the actual visual effect.
Scroll-Triggered Animation System
class ScrollAnimator {
constructor(options = {}) {
this.animatedCount = 0;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: options.rootMargin || '-50px 0px',
threshold: options.threshold || 0.15
}
);
}
init(selector = '[data-animate]') {
const elements = document.querySelectorAll(selector);
elements.forEach(el => {
// Set initial hidden state
el.classList.add('scroll-hidden');
// Parse animation delay from data attribute
const delay = el.dataset.animateDelay || '0';
el.style.transitionDelay = delay + 'ms';
this.observer.observe(el);
});
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const animationType = el.dataset.animate || 'fade-up';
// Add the animation class
el.classList.remove('scroll-hidden');
el.classList.add('scroll-visible', `animate-${animationType}`);
this.animatedCount++;
// Unobserve: animate only once
this.observer.unobserve(el);
}
});
}
destroy() {
this.observer.disconnect();
}
}
// Initialize the animation system
const animator = new ScrollAnimator({
rootMargin: '-80px 0px', // Trigger 80px after entering viewport
threshold: 0.15 // At least 15% visible
});
animator.init();
// HTML usage:
// <div data-animate="fade-up">Fades up into view</div>
// <div data-animate="fade-left" data-animate-delay="200">Slides in from left</div>
// <div data-animate="scale" data-animate-delay="400">Scales up</div>
// CSS:
// .scroll-hidden {
// opacity: 0;
// transform: translateY(30px);
// transition: opacity 0.6s ease, transform 0.6s ease;
// }
// .scroll-visible { opacity: 1; transform: none; }
// .animate-fade-left.scroll-hidden { transform: translateX(-30px); }
// .animate-scale.scroll-hidden { transform: scale(0.9); }
This pattern is powerful because the Intersection Observer handles the detection efficiently, while CSS handles the animation smoothly on the GPU. The JavaScript layer is minimal -- it simply toggles classes. The data-animate attribute on each element determines which animation style is applied, and the optional data-animate-delay creates staggered entrance effects when multiple elements enter the viewport simultaneously.
Sticky Header Detection
A common UI pattern involves changing a header's appearance when the user scrolls past a certain point -- adding a shadow, changing the background color, or shrinking the height. Instead of checking window.scrollY on every scroll event, you can observe a sentinel element placed right below the header. When the sentinel scrolls out of view, the header becomes "stuck."
Sticky Header with Intersection Observer
// HTML:
// <div id="header-sentinel"></div>
// <header class="site-header">...</header>
class StickyHeaderDetector {
constructor(headerEl, sentinelEl) {
this.header = headerEl;
this.sentinel = sentinelEl;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '0px',
threshold: 0
}
);
this.observer.observe(this.sentinel);
}
handleIntersection(entries) {
const entry = entries[0];
if (!entry.isIntersecting) {
// Sentinel is above the viewport: header is stuck
this.header.classList.add('header--stuck');
this.header.setAttribute('aria-label', 'Fixed navigation');
} else {
// Sentinel is visible: header is in normal position
this.header.classList.remove('header--stuck');
this.header.removeAttribute('aria-label');
}
}
destroy() {
this.observer.disconnect();
}
}
// CSS for the sticky header:
// .site-header {
// position: sticky;
// top: 0;
// transition: box-shadow 0.3s ease, background-color 0.3s ease;
// }
// .header--stuck {
// box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
// background-color: rgba(255, 255, 255, 0.98);
// }
const header = document.querySelector('.site-header');
const sentinel = document.getElementById('header-sentinel');
const stickyDetector = new StickyHeaderDetector(header, sentinel);
Ad Viewability Tracking
In digital advertising, viewability measures whether an ad was actually seen by a user. The industry standard (defined by the Media Rating Council) requires that at least 50% of the ad's pixels are visible in the viewport for at least one continuous second. The Intersection Observer is perfectly suited for this measurement because it can track the exact visibility ratio without expensive scroll calculations.
Ad Viewability Tracker
class AdViewabilityTracker {
constructor() {
this.viewabilityTimers = new Map();
this.reportedAds = new Set();
this.VIEWABILITY_THRESHOLD = 0.5; // 50% visible
this.VIEWABILITY_DURATION = 1000; // 1 second
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '0px',
threshold: [0, 0.25, 0.5, 0.75, 1.0]
}
);
}
trackAd(adElement) {
if (!adElement.id) {
adElement.id = 'ad-' + Math.random().toString(36).slice(2, 9);
}
this.observer.observe(adElement);
}
handleIntersection(entries) {
entries.forEach(entry => {
const adId = entry.target.id;
// Already reported -- skip
if (this.reportedAds.has(adId)) return;
if (entry.intersectionRatio >= this.VIEWABILITY_THRESHOLD) {
// Ad meets visibility threshold -- start timer
if (!this.viewabilityTimers.has(adId)) {
const timerId = setTimeout(() => {
this.reportViewable(entry.target);
}, this.VIEWABILITY_DURATION);
this.viewabilityTimers.set(adId, timerId);
}
} else {
// Ad no longer meets threshold -- cancel timer
if (this.viewabilityTimers.has(adId)) {
clearTimeout(this.viewabilityTimers.get(adId));
this.viewabilityTimers.delete(adId);
}
}
});
}
reportViewable(adElement) {
const adId = adElement.id;
this.reportedAds.add(adId);
this.viewabilityTimers.delete(adId);
// Send viewability event to analytics
console.log(`Ad ${adId} is viewable (50%+ visible for 1+ second)`);
// Stop observing this ad
this.observer.unobserve(adElement);
// Send to analytics endpoint
navigator.sendBeacon('/api/analytics/viewability', JSON.stringify({
adId: adId,
timestamp: Date.now(),
placement: adElement.dataset.placement
}));
}
destroy() {
this.viewabilityTimers.forEach(timerId => clearTimeout(timerId));
this.viewabilityTimers.clear();
this.observer.disconnect();
}
}
// Track all ad units on the page
const viewabilityTracker = new AdViewabilityTracker();
document.querySelectorAll('.ad-unit').forEach(ad => {
viewabilityTracker.trackAd(ad);
});
Section-Based Navigation Highlighting
Many single-page sites and documentation pages feature a navigation menu that highlights the currently visible section. The Intersection Observer makes this straightforward: observe all section elements, and when a section enters the viewport, update the navigation to highlight the corresponding link. This replaces the traditional approach of calculating scroll position against each section's offset on every scroll event.
Active Section Navigation Highlighting
class SectionNavigator {
constructor(navSelector, sectionSelector) {
this.navLinks = document.querySelectorAll(`${navSelector} a`);
this.sections = document.querySelectorAll(sectionSelector);
this.currentSection = null;
// Use negative rootMargin to require section to be
// partially in the upper half of the viewport
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '-20% 0px -60% 0px',
threshold: 0
}
);
this.sections.forEach(section => {
this.observer.observe(section);
});
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.setActiveSection(entry.target.id);
}
});
}
setActiveSection(sectionId) {
if (this.currentSection === sectionId) return;
this.currentSection = sectionId;
// Remove active class from all links
this.navLinks.forEach(link => {
link.classList.remove('nav-active');
link.removeAttribute('aria-current');
});
// Add active class to the matching link
const activeLink = document.querySelector(
`a[href="#${sectionId}"]`
);
if (activeLink) {
activeLink.classList.add('nav-active');
activeLink.setAttribute('aria-current', 'true');
}
}
destroy() {
this.observer.disconnect();
}
}
// HTML structure:
// <nav class="table-of-contents">
// <a href="#introduction">Introduction</a>
// <a href="#getting-started">Getting Started</a>
// <a href="#advanced">Advanced Usage</a>
// <a href="#api-reference">API Reference</a>
// </nav>
//
// <section id="introduction">...</section>
// <section id="getting-started">...</section>
// <section id="advanced">...</section>
// <section id="api-reference">...</section>
const sectionNav = new SectionNavigator(
'.table-of-contents',
'section[id]'
);
'-20% 0px -60% 0px' creates a detection zone in the upper portion of the viewport. This means a section is considered "active" when it occupies the top 20-40% of the screen, which aligns with how users naturally perceive which section they are reading. Adjust these percentages based on your content layout and header height.Performance: Intersection Observer vs Scroll Events
The performance advantage of Intersection Observer over scroll event listeners is substantial and measurable. Scroll events fire synchronously on the main thread, blocking other JavaScript execution and potentially causing jank. Every call to getBoundingClientRect() inside a scroll handler forces the browser to perform a synchronous layout recalculation. When you observe multiple elements, this cost multiplies.
The Intersection Observer operates asynchronously and is optimized by the browser at a low level. The browser batches intersection checks and runs them at an appropriate time, typically during idle periods or before paint. It does not force layout recalculations because it uses cached geometry data from the browser's internal rendering pipeline.
Performance Comparison: Scroll Event vs Intersection Observer
// BAD: Scroll event approach (synchronous, main thread)
function checkVisibilityWithScroll() {
const elements = document.querySelectorAll('.track-visibility');
window.addEventListener('scroll', function() {
// This runs on EVERY scroll event (potentially 100+ per second)
elements.forEach(el => {
// getBoundingClientRect() forces synchronous layout
const rect = el.getBoundingClientRect();
const isVisible = (
rect.top < window.innerHeight &&
rect.bottom > 0
);
if (isVisible) {
el.classList.add('visible');
}
});
});
}
// Problems:
// - Runs on every scroll event (high frequency)
// - Forces synchronous layout for each element
// - Blocks main thread
// - CPU usage spikes during scroll
// GOOD: Intersection Observer approach (asynchronous, off main thread)
function checkVisibilityWithObserver() {
const observer = new IntersectionObserver((entries) => {
// Only fires when intersection changes -- not on every scroll
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0 });
document.querySelectorAll('.track-visibility').forEach(el => {
observer.observe(el);
});
}
// Benefits:
// - Fires only on intersection change (low frequency)
// - No layout recalculation
// - Runs asynchronously off the main thread
// - Minimal CPU usage during scroll
// MEASUREMENT: Quantify the difference
let scrollCallCount = 0;
let observerCallCount = 0;
window.addEventListener('scroll', () => scrollCallCount++, { passive: true });
const measureObserver = new IntersectionObserver(() => {
observerCallCount++;
}, { threshold: [0, 0.5, 1.0] });
// After 10 seconds of scrolling:
// scrollCallCount might be 800+
// observerCallCount might be 12
In benchmarks observing 100 elements on a page, the scroll event approach can consume 15-25ms of main thread time per scroll event, causing visible jank at 60fps (which allows only 16.7ms per frame). The Intersection Observer approach for the same 100 elements typically adds less than 1ms of overhead per intersection change, and these checks happen asynchronously, never blocking the rendering pipeline.
Combining Multiple Observers
In a real application, you often need multiple intersection-based features on the same page: lazy loading images, scroll animations, section navigation, and analytics tracking. Each feature can use its own observer instance with different options, or you can share a single observer when the configuration is identical. The key principle is to use the minimum number of observers with the minimum threshold granularity needed.
Coordinating Multiple Observers on One Page
class PageIntersectionManager {
constructor() {
this.observers = new Map();
}
createObserver(name, callback, options = {}) {
const observer = new IntersectionObserver(callback, {
root: options.root || null,
rootMargin: options.rootMargin || '0px',
threshold: options.threshold || 0
});
this.observers.set(name, observer);
return observer;
}
initAll() {
// 1. Lazy loading: generous rootMargin, simple threshold
const lazyObserver = this.createObserver('lazy', (entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadLazyImage(entry.target);
obs.unobserve(entry.target);
}
});
}, { rootMargin: '300px 0px', threshold: 0 });
document.querySelectorAll('img[data-src]').forEach(img => {
lazyObserver.observe(img);
});
// 2. Scroll animations: negative rootMargin, low threshold
const animObserver = this.createObserver('animations', (entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
obs.unobserve(entry.target);
}
});
}, { rootMargin: '-50px 0px', threshold: 0.15 });
document.querySelectorAll('[data-animate]').forEach(el => {
animObserver.observe(el);
});
// 3. Section navigation: custom rootMargin for reading position
const navObserver = this.createObserver('navigation', (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
highlightNavLink(entry.target.id);
}
});
}, { rootMargin: '-20% 0px -60% 0px', threshold: 0 });
document.querySelectorAll('section[id]').forEach(section => {
navObserver.observe(section);
});
// 4. Analytics: track time spent in each section
const analyticsObserver = this.createObserver('analytics', (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
startSectionTimer(entry.target.id);
} else {
stopSectionTimer(entry.target.id);
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.trackable-section').forEach(section => {
analyticsObserver.observe(section);
});
}
destroyAll() {
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
}
destroy(name) {
const observer = this.observers.get(name);
if (observer) {
observer.disconnect();
this.observers.delete(name);
}
}
}
// Usage
const pageManager = new PageIntersectionManager();
pageManager.initAll();
// Cleanup on page navigation
window.addEventListener('beforeunload', () => {
pageManager.destroyAll();
});
observe() for each element that shares that configuration. One observer can efficiently watch hundreds of elements.Practice Exercise
Build a comprehensive demo page that uses the Intersection Observer API for four different features working together. First, create a page with 20 placeholder image cards that use lazy loading with a 300px rootMargin. Display a counter showing how many images have been loaded out of the total. Second, implement scroll-triggered animations on at least 10 content blocks using three different animation types (fade-up, fade-left, scale) with staggered delays. Third, add a sticky sidebar navigation that highlights the current section as the user scrolls through at least five content sections. The highlighting should use a rootMargin of -20% 0px -60% 0px so it activates when sections reach the upper portion of the viewport. Fourth, add an infinite scroll section at the bottom that loads simulated article excerpts (use setTimeout to simulate a network request) and displays a loading spinner while fetching. Include a performance panel that shows how many observer callbacks have fired versus how many scroll events occurred during the same period. Finally, ensure all observers are properly cleaned up when the page unloads. This exercise will give you hands-on experience with every major use case of the Intersection Observer API and demonstrate its performance advantages over scroll events.