Intersection Observer — Detecting Element Visibility in the Viewport
In this tutorial, you will learn about Intersection Observer. We cover key concepts, practical examples, and best practices to help you master this topic.
Intersection Observer efficiently detects when elements enter or exit the viewport, enabling custom Lazy Loading, Infinite Scroll, and visibility-based animations.
What You'll Learn
By the end of this tutorial, you'll understand how Intersection Observer works, how to configure thresholds and root margins, how to implement custom lazy loading, and how to avoid common performance pitfalls.
Why It Matters
Before Intersection Observer, detecting element visibility required scroll event listeners that triggered layout thrashing and poor performance. Intersection Observer provides a declarative, efficient, callback-based API that runs off the main thread.
Real-World Use
A social media feed uses Intersection Observer to lazy load profile images as users scroll. The observer detects when images enter the viewport, swaps the placeholder src for the real image, and unobserve the element to stop monitoring.
Intersection Observer Flow
graph TD
A[Create Observer] --> B[Configure options
root, rootMargin, threshold]
B --> C[Observe
target elements]
C --> D[Browser tracks
element visibility]
D --> E{Element intersects
viewport?}
E -->|No| F[Keep observing]
E -->|Yes| G[Callback fires
with entry data]
G --> H[Load resource
or trigger action]
H --> I[Unobserve element]
I --> J[Stop tracking
this element]
style A fill:#4a90d9,color:#fff
style D fill:#e67e22,color:#fff
style G fill:#f39c12,color:#fff
style H fill:#27ae60,color:#fff
Basic Intersection Observer
// Create the observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
console.log(`Loaded: ${img.dataset.src}`);
}
});
}, {
root: null, // Use viewport as root
rootMargin: '200px', // Start loading 200px before visible
threshold: 0.01 // Trigger when 1% visible
});
// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
Advanced Configuration
// 1. Multiple thresholds for progress tracking
const progressObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// entry.intersectionRatio gives 0.0 to 1.0
const visibility = Math.round(entry.intersectionRatio * 100);
if (entry.intersectionRatio > 0.5) {
console.log(`Element is more than 50% visible: ${visibility}%`);
}
// Update a progress indicator
entry.target.style.opacity = entry.intersectionRatio;
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1] // Trigger at each 25% step
});
// 2. Root margin for pre-loading
const preloadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Load content before user scrolls to it
loadContent(entry.target.dataset.contentId);
preloadObserver.unobserve(entry.target);
}
});
}, {
rootMargin: '500px 0px', // 500px above and below viewport
threshold: 0
});
// 3. Element-level root (scrollable container)
const container = document.querySelector('.scrollable-list');
const containerObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMoreItems();
}
});
}, {
root: container,
rootMargin: '100px',
threshold: 0
});
Custom Lazy Loading with Intersection Observer
// Full-featured lazy loading utility
class LazyLoader {
constructor(options = {}) {
this.options = {
root: options.root || null,
rootMargin: options.rootMargin || '200px 0px',
threshold: options.threshold || 0.01,
onLoad: options.onLoad || null
};
this.observer = new IntersectionObserver(
(entries) => this.handleIntersect(entries),
this.options
);
this.loadedCount = 0;
this.totalCount = 0;
}
observe(element, loadCallback) {
this.totalCount++;
element.dataset.lazyId = `lazy-${this.totalCount}`;
element._onLoad = loadCallback;
this.observer.observe(element);
}
handleIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
// Call the load function
if (typeof element._onLoad === 'function') {
element._onLoad(element, () => {
this.loadedCount++;
this.observer.unobserve(element);
if (this.options.onLoad) {
this.options.onLoad(element, this.loadedCount, this.totalCount);
}
});
}
this.observer.unobserve(element);
}
});
}
disconnect() {
this.observer.disconnect();
}
getStats() {
return {
loaded: this.loadedCount,
total: this.totalCount,
remaining: this.totalCount - this.loadedCount
};
}
}
// Usage
const lazyLoader = new LazyLoader({
rootMargin: '300px',
onLoad: (element, loaded, total) => {
console.log(`Loaded ${loaded}/${total} elements`);
}
});
document.querySelectorAll('.lazy-image').forEach(img => {
lazyLoader.observe(img, (element, done) => {
element.src = element.dataset.src;
element.onload = () => {
element.classList.add('loaded');
done();
};
});
});
Infinite Scroll Implementation
// Infinite scroll with Intersection Observer
class InfiniteScroll {
constructor(options) {
this.loadMore = options.loadMore;
this.container = options.container;
this.sentinel = options.sentinel;
this.observer = new IntersectionObserver(
(entries) => this.handleIntersect(entries),
{ rootMargin: '100px' }
);
this.observer.observe(this.sentinel);
this.isLoading = false;
}
async handleIntersect(entries) {
const entry = entries[0];
if (!entry.isIntersecting || this.isLoading) return;
this.isLoading = true;
this.sentinel.classList.add('loading');
try {
await this.loadMore();
console.log('Loaded more items');
} catch (err) {
console.error('Failed to load more:', err);
} finally {
this.isLoading = false;
this.sentinel.classList.remove('loading');
}
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const sentinel = document.getElementById('scroll-sentinel');
const container = document.getElementById('content-container');
const infiniteScroll = new InfiniteScroll({
container,
sentinel,
loadMore: async () => {
const response = await fetch('/api/items?page=' + currentPage++);
const items = await response.json();
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.title;
container.appendChild(div);
});
}
});
Common Mistakes
- Creating a new observer for every element. One observer can monitor hundreds of elements. Creating multiple observers wastes resources.
- Forgetting to unobserve after loading. Observed elements continue to trigger callbacks. Always unobserve after the action is complete.
- Using scroll events instead of Intersection Observer. Scroll events trigger on every pixel scrolled. Intersection Observer fires only at thresholds — much more efficient.
- Not checking entry.isIntersecting. The callback fires for both entering and exiting. Check isIntersecting to distinguish.
- Setting rootMargin too large. A 5000px rootMargin loads content way before needed. Use 200-500px for most cases.
Practice Questions
- How does Intersection Observer differ from scroll event listeners?
- What do rootMargin and threshold control in Intersection Observer?
- When should you unobserve an element after intersection?
- How do you implement infinite scroll with Intersection Observer?
- What happens if you don't call unobserve after the element is loaded?
Challenge: Build a performance comparison between scroll event-based lazy loading and Intersection Observer: measure scroll handler call count, CPU time, and frame drops during scrolling for each approach.
FAQ
Mini Project
Build a custom lazy loading library using Intersection Observer: support images, iframes, background images, and video elements. Include configurable rootMargin, a loading spinner, error handling for failed loads, and a real-time performance dashboard.
What's Next
You've mastered Intersection Observer. Now apply it to Image Lazy Loading for comprehensive image deferral strategies.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro