Intersection Observer — Complete Guide
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 detects when elements become visible or hidden in the viewport, enabling Lazy Loading, Infinite Scroll, and scroll-triggered animations without expensive scroll event listeners.
What You'll Learn
- How to create an Intersection Observer with options
- How to detect when elements enter or leave the viewport
- How to implement lazy loading of images
- How to trigger animations on scroll
- How to implement infinite scroll
Why It Matters
Before Intersection Observer, detecting element visibility required scroll event listeners with expensive layout calculations (getBoundingClientRect). Intersection Observer provides a performant, callback-based API that the browser optimizes internally.
Real-World Use
- Medium lazily loads images as they scroll into view
- Twitter loads more tweets when the user scrolls near the bottom
- A landing page animates elements as they scroll into view
- Analytics track how long an ad is visible to the user
flowchart LR
A[Create Observer] --> B[new IntersectionObserver]
B --> C[Observe Element]
C --> D[User Scrolls]
D --> E[Intersection Changes]
E --> F[Callback Fires]
F --> G{isIntersecting?}
G -->|Yes| H[Element is visible]
G -->|No| I[Element is hidden]
H --> J[Load / Animate / Track]
Creating an Intersection Observer
The observer takes a callback and an optional options object.
const target = document.querySelector('.observed-element');
// Create the observer
const observer = new IntersectionObserver(function(entries) {
entries.forEach(entry => {
console.log('Entry:', {
target: entry.target,
isIntersecting: entry.isIntersecting,
intersectionRatio: entry.intersectionRatio,
boundingClientRect: entry.boundingClientRect,
intersectionRect: entry.intersectionRect,
rootBounds: entry.rootBounds,
time: entry.time
});
if (entry.isIntersecting) {
console.log('Element is visible');
} else {
console.log('Element is hidden');
}
});
}, {
// root: null means viewport (default)
rootMargin: '0px',
threshold: 0.5 // Fire when 50% visible
});
// Start observing
observer.observe(target);
// Later: stop observing
// observer.unobserve(target);
// observer.disconnect(); // Stop all observations
Expected output: Scrolling the page so the target element crosses the visibility threshold triggers the callback. The entry shows intersection details including ratio and rectangles.
Options: rootMargin and threshold
These options control when the callback fires.
// rootMargin: expand or shrink the root's bounding box
// Values: CSS-like syntax "top right bottom left"
const earlyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is near viewport');
entry.target.classList.add('visible');
earlyObserver.unobserve(entry.target); // Fire once
}
});
}, {
rootMargin: '200px', // Fire when element is 200px outside viewport
threshold: 0
});
// Multiple thresholds
const multiObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log(`Threshold ${entry.threshold}: ratio ${entry.intersectionRatio.toFixed(2)}`);
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1.0]
// Fires at 0%, 25%, 50%, 75%, and 100% visibility
});
// Observe elements
document.querySelectorAll('.animate-on-scroll').forEach(el => {
earlyObserver.observe(el);
});
Expected output: Elements with earlyObserver get the visible class 200px before they enter the viewport. Elements with multiObserver fire the callback at each threshold crossing.
Lazy Loading Images
Load images only when they are about to enter the viewport.
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.dataset.src;
if (src) {
console.log('Loading image:', src);
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
// Fade in the image
img.addEventListener('load', function() {
this.style.opacity = '1';
});
}
// Stop observing once loaded
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px', // Start loading 200px before visible
threshold: 0.01
});
// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
// HTML: <img data-src="real-image.jpg" alt="Lazy" class="lazy-image">
// CSS: .lazy-image { opacity: 0; transition: opacity 0.3s; }
Expected output: Images load only when they scroll within 200px of the viewport. The data-src attribute contains the real URL. After loading, the image fades in and the observer stops watching it.
Infinite Scroll
Load more content when the user scrolls near the bottom of the list.
const sentinel = document.querySelector('.scroll-sentinel');
const contentContainer = document.querySelector('.content-container');
let page = 0;
const sentinelObserver = new IntersectionObserver(async (entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
console.log('Loading more content, page:', page + 1);
page++;
// Load more content
const items = await fetchMoreItems(page);
renderItems(items);
// If no more items, stop observing
if (items.length === 0) {
sentinelObserver.unobserve(sentinel);
sentinel.textContent = 'No more items';
console.log('All items loaded');
}
}
}, {
rootMargin: '100px', // Fire 100px before sentinel is visible
threshold: 0
});
sentinelObserver.observe(sentinel);
// Simulated data fetch
async function fetchMoreItems(page) {
// In production: fetch from API
// const response = await fetch(`/api/items?page=${page}`);
// return response.json();
return Array.from({ length: 10 }, (_, i) => ({
id: page * 10 + i,
text: `Item ${page * 10 + i + 1}`
}));
}
function renderItems(items) {
items.forEach(item => {
const div = document.createElement('div');
div.className = 'item';
div.textContent = item.text;
contentContainer.appendChild(div);
});
}
Expected output: As the user scrolls to the bottom, the sentinel element comes near the viewport and triggers loading of the next page. The sentinel is always at the bottom of the content.
Scroll-Triggered Animations
Animate elements when they enter the viewport.
const animationObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
const animation = element.dataset.animation || 'fadeIn';
console.log('Animating:', element.className, 'with', animation);
element.classList.add('animated', animation);
// Optionally stop observing after animation
observer.unobserve(element);
}
});
}, {
threshold: 0.2, // 20% visible
rootMargin: '-50px' // Small offset from edge
});
// Observe animated elements
document.querySelectorAll('[data-animation]').forEach(el => {
animationObserver.observe(el);
});
// CSS examples:
// .animated.fadeIn { animation: fadeIn 0.6s ease forwards; }
// .animated.slideUp { animation: slideUp 0.6s ease forwards; }
// .animated.scaleIn { animation: scaleIn 0.5s ease forwards; }
// @keyframes fadeIn {
// from { opacity: 0; }
// to { opacity: 1; }
// }
// @keyframes slideUp {
// from { opacity: 0; transform: translateY(30px); }
// to { opacity: 1; transform: translateY(0); }
// }
Expected output: Elements with data-animation attributes animate when they scroll into view. Different elements can have different animation types. Each element animates only once.
Tracking Visibility for Analytics
Measure how long an element is visible to the user.
const adContainer = document.querySelector('.ad-container');
let visibleStartTime = 0;
let totalVisibleTime = 0;
const visibilityObserver = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
visibleStartTime = Date.now();
console.log('Ad became visible');
// Track ad impression
trackImpression('ad-banner-1');
} else {
if (visibleStartTime > 0) {
const visibleDuration = Date.now() - visibleStartTime;
totalVisibleTime += visibleDuration;
console.log('Ad hidden after', visibleDuration, 'ms visible');
console.log('Total visible time:', totalVisibleTime, 'ms');
// Track viewability
trackViewability('ad-banner-1', totalVisibleTime);
visibleStartTime = 0;
}
}
}, {
threshold: [0.5] // Consider visible when 50% shown
});
visibilityObserver.observe(adContainer);
function trackImpression(adId) {
console.log('Impression tracked:', adId);
// fetch('/api/track/impression', { method: 'POST', body: JSON.stringify({ adId }) });
}
function trackViewability(adId, durationMs) {
console.log('Viewability tracked:', adId, '-', durationMs, 'ms');
// fetch('/api/track/viewability', { method: 'POST', body: JSON.stringify({ adId, durationMs }) });
}
Expected output: When the ad scrolls into view, the impression is tracked. When it scrolls out, the viewable duration is calculated and tracked. Multiple visibility cycles accumulate total visible time.
Common Mistakes
- Not cleaning up observers — Failing to call disconnect() or unobserve() can cause memory leaks, especially in single-page applications where elements are dynamically created and destroyed.
- Setting rootMargin too large — A rootMargin of 1000px starts loading content a full viewport away. This defeats the purpose of lazy loading. Use 100-300px for a good balance.
- Using threshold: 0 when you want early loading — threshold 0 fires as soon as 1 pixel is visible. Use rootMargin with threshold 0 for pre-loading.
- Observing the same element with multiple observers — Each observer adds overhead. Consolidate logic into one observer with multiple conditional checks.
- Forgetting that the callback runs on the main thread — Heavy work inside the observer callback blocks rendering. Keep the callback lightweight or use requestIdleCallback.
Practice Questions
- What does rootMargin '100px' do? It expands the root's bounding box by 100px on all sides, causing the callback to fire when the element is 100px away from the viewport.
- What is the difference between isIntersecting and intersectionRatio > 0? isIntersecting is a boolean. intersectionRatio is a number from 0 to 1. isIntersecting is true when intersectionRatio > 0.
- Why is Intersection Observer better than scroll event listeners for visibility detection? The browser optimizes intersection calculations internally. Scroll event listeners trigger expensive getBoundingClientRect calls on every scroll frame.
- Challenge: Build a reading progress indicator. Use an Intersection Observer on each section heading. As the user scrolls through an article, highlight the current section in a sidebar table of contents. Track which sections have been read.
FAQ
Mini Project
Build a long-form article page with the following scroll-based features: 1) Lazy-loaded images using data-src, 2) Animate section headings with a slide-in effect when they scroll into view, 3) A progress bar at the top showing how far the user has scrolled through the article, 4) An infinite-scroll related articles section at the bottom. All features must use Intersection Observer.
What's Next
Continue with Lesson 21: Mutation Observer to learn how to watch for DOM changes like element additions, removals, and attribute modifications.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro