Intersection Observer API
Introduction to Intersection Observer
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 document's viewport. This allows you to efficiently detect when elements enter or exit the viewport, enabling performance-optimized implementations of:
- Lazy loading of images and other content
- Infinite scrolling of content
- Scroll-based animations and effects
- Ad visibility tracking and analytics
- Sticky headers and navigation elements
Why Use Intersection Observer?
Before Intersection Observer, detecting visibility required event handlers and expensive DOM queries:
// The old way (avoid this)
window.addEventListener('scroll', function() {
// This runs on every scroll event (performance nightmare)
const element = document.querySelector('.my-element');
const position = element.getBoundingClientRect();
// Check if element is visible
if (position.top < window.innerHeight && position.bottom >= 0) {
console.log('Element is visible!');
}
});
This approach has several problems:
- Scroll event handlers run on the main thread
- They fire many times during scrolling (potential performance bottleneck)
- getBoundingClientRect() forces layout recalculation
Basic Usage
Creating an Observer
The basic pattern for using Intersection Observer involves creating an observer with a callback function and options, then observing one or more target elements:
// Step 1: Create a callback function
const callback = (entries, observer) => {
entries.forEach(entry => {
// entry.isIntersecting is true when element is visible
if (entry.isIntersecting) {
console.log('Element is visible in the viewport!');
// Optional: Stop observing the element
// observer.unobserve(entry.target);
} else {
console.log('Element is not visible in the viewport!');
}
});
};
// Step 2: Create observer with options
const options = {
root: null, // Use the viewport as the root
rootMargin: '0px', // No margin around the root
threshold: 0.5 // Trigger when 50% of the element is visible
};
const observer = new IntersectionObserver(callback, options);
// Step 3: Start observing an element
const target = document.querySelector('.my-element');
observer.observe(target);
// Step 4 (Optional): Stop observing when no longer needed
// observer.unobserve(target);
// Step 5 (Optional): Disconnect the observer completely when done
// observer.disconnect();
Understanding the Options
Option | Description | Example Values |
---|---|---|
root |
The element that is used as the viewport for checking visibility. Must be an ancestor of the target. |
null (default, uses viewport)document.querySelector('.container')
|
rootMargin |
Margin around the root, effectively growing or shrinking the area used for intersection. Uses CSS margin syntax. |
'0px' (default)'10px 20px 30px 40px' (top, right, bottom, left)'-10px' (negative values shrink the root area)
|
threshold |
Percentage of the target's visibility the observer's callback should be executed. Can be a single number or an array of numbers. |
0 (as soon as one pixel is visible)1.0 (when the entire element is visible)0.5 (when 50% is visible)[0, 0.25, 0.5, 0.75, 1] (multiple thresholds)
|
The Intersection Entry Object
The callback receives an array of IntersectionObserverEntry objects with these key properties:
const callback = (entries, observer) => {
entries.forEach(entry => {
console.log('Target element:', entry.target);
console.log('Is intersecting:', entry.isIntersecting);
console.log('Intersection ratio:', entry.intersectionRatio);
console.log('Bounding client rect:', entry.boundingClientRect);
console.log('Intersection rect:', entry.intersectionRect);
console.log('Root bounds:', entry.rootBounds);
console.log('Time since observation began:', entry.time);
});
};
Practical Examples
Lazy Loading Images
One of the most common uses for Intersection Observer is lazy loading images to improve page load performance:
// HTML structure:
// <img class="lazy-image" data-src="actual-image-url.jpg" src="placeholder.jpg" alt="Description">
document.addEventListener('DOMContentLoaded', function() {
const lazyImages = document.querySelectorAll('.lazy-image');
// Create an intersection observer
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// If the image is in viewport
if (entry.isIntersecting) {
const img = entry.target;
// Replace the placeholder with the actual image
img.src = img.dataset.src;
// Optional: Add a class for fade-in effect
img.classList.add('loaded');
// Stop observing the image
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px', // Start loading images before they appear in viewport
threshold: 0.01 // Trigger when just 1% of the image is visible
});
// Observe all lazy images
lazyImages.forEach(image => {
imageObserver.observe(image);
});
});
// CSS for fade-in effect
/*
.lazy-image {
opacity: 0;
transition: opacity 0.3s ease-in;
}
.lazy-image.loaded {
opacity: 1;
}
*/
Infinite Scrolling
Implement infinite scrolling by loading more content when the user reaches the bottom of the page:
// HTML structure:
// <div id="content">...initial content...</div>
// <div id="loading-indicator">Loading more items...</div>
let page = 1;
let isLoading = false;
document.addEventListener('DOMContentLoaded', function() {
const loadingIndicator = document.getElementById('loading-indicator');
// Create an intersection observer for the loading indicator
const loadMoreObserver = new IntersectionObserver((entries, observer) => {
// If loading indicator is visible and we're not already loading
if (entries[0].isIntersecting && !isLoading) {
loadMoreContent();
}
}, {
rootMargin: '100px 0px' // Start loading before user reaches the very bottom
});
// Start observing the loading indicator
loadMoreObserver.observe(loadingIndicator);
// Function to load more content
async function loadMoreContent() {
isLoading = true;
try {
// Show loading state
loadingIndicator.textContent = 'Loading more items...';
// Fetch next page of content
const response = await fetch(`/api/content?page=${++page}`);
if (!response.ok) throw new Error('Failed to load more content');
const data = await response.json();
// If no more data, stop observing
if (data.items.length === 0) {
loadingIndicator.textContent = 'No more content to load';
loadMoreObserver.unobserve(loadingIndicator);
return;
}
// Append new content to the page
const contentContainer = document.getElementById('content');
data.items.forEach(item => {
const itemElement = document.createElement('div');
itemElement.classList.add('content-item');
itemElement.innerHTML = `
${item.title}
${item.description}
`;
contentContainer.appendChild(itemElement);
});
// Reset loading state
loadingIndicator.textContent = 'Scroll for more';
} catch (error) {
console.error('Error loading more content:', error);
loadingIndicator.textContent = 'Error loading content. Try again.';
} finally {
isLoading = false;
}
}
});
Scroll-Based Animations
Trigger animations when elements come into view:
// HTML structure:
// <div class="animate-on-scroll" data-animation="fade-in">...</div>
// <div class="animate-on-scroll" data-animation="slide-up">...</div>
document.addEventListener('DOMContentLoaded', function() {
const animatedElements = document.querySelectorAll('.animate-on-scroll');
// Create animation observer
const animationObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Get the animation type from data attribute
const animationType = entry.target.dataset.animation;
// Add the animation class
entry.target.classList.add(animationType);
// Stop observing after animation is triggered
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.2 // Trigger when 20% of the element is visible
});
// Observe all animated elements
animatedElements.forEach(element => {
animationObserver.observe(element);
});
});
// CSS for animations
/*
.animate-on-scroll {
opacity: 0;
transition: all 0.5s ease-out;
}
.fade-in {
opacity: 1;
}
.slide-up {
opacity: 1;
transform: translateY(0);
}
.animate-on-scroll[data-animation="slide-up"] {
transform: translateY(20px);
}
*/
Advanced Techniques
Using a Custom Root Element
You can observe intersections within a scrollable container instead of the viewport:
// HTML structure:
// <div id="scrollable-container">
// <div class="item">Item 1</div>
// <div class="item">Item 2</div>
// ...
// </div>
const container = document.getElementById('scrollable-container');
const items = container.querySelectorAll('.item');
// Create observer with the container as root
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
} else {
entry.target.classList.remove('visible');
}
});
}, {
root: container, // Use the container as the viewport
threshold: 0.5 // 50% visibility threshold
});
// Observe all items
items.forEach(item => {
observer.observe(item);
});
Multiple Thresholds for Progressive Effects
Use multiple thresholds to create progressive effects based on how much of an element is visible:
const progressiveObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// Get the target element
const element = entry.target;
// Calculate opacity based on intersection ratio
const opacity = Math.round(entry.intersectionRatio * 10) / 10;
// Apply styles based on visibility
element.style.opacity = opacity;
// Apply different classes based on thresholds
if (entry.intersectionRatio >= 0.75) {
element.classList.add('fully-visible');
element.classList.remove('partially-visible');
} else if (entry.intersectionRatio >= 0.25) {
element.classList.add('partially-visible');
element.classList.remove('fully-visible');
} else {
element.classList.remove('partially-visible', 'fully-visible');
}
console.log(`Element visibility: ${entry.intersectionRatio * 100}%`);
});
}, {
threshold: [0, 0.25, 0.5, 0.75, 1] // Multiple thresholds
});
// Observe elements
document.querySelectorAll('.progressive-element').forEach(element => {
progressiveObserver.observe(element);
});
Sticky Navigation with Intersection Observer
Create a sticky header that appears when scrolling past a certain point:
// HTML structure:
// <div id="header-sentinel"></div>
// <header id="sticky-header">...</header>
document.addEventListener('DOMContentLoaded', function() {
const sentinel = document.getElementById('header-sentinel');
const header = document.getElementById('sticky-header');
// Create observer for the sentinel element
const headerObserver = new IntersectionObserver((entries) => {
// When sentinel leaves viewport (scrolled past it)
if (!entries[0].isIntersecting) {
header.classList.add('sticky');
} else {
header.classList.remove('sticky');
}
}, {
threshold: 0,
rootMargin: '0px'
});
// Start observing the sentinel
headerObserver.observe(sentinel);
});
// CSS for sticky header
/*
#header-sentinel {
position: absolute;
top: 0;
left: 0;
height: 1px;
width: 100%;
z-index: -1;
}
#sticky-header {
position: relative;
background: white;
padding: 15px;
z-index: 100;
transition: all 0.3s ease;
}
#sticky-header.sticky {
position: fixed;
top: 0;
left: 0;
right: 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
*/
Performance Considerations
Best Practices
- Reuse observers: Create a single observer for multiple elements with the same behavior
- Unobserve when done: Call
unobserve()
once you no longer need to track an element - Use appropriate thresholds: Only use the thresholds you actually need
- Consider rootMargin: Use rootMargin to start loading before elements are visible
- Avoid heavy operations: Keep your callback functions light and efficient
Potential Pitfalls
- Too many observers: Creating a new observer for each element can impact performance
- Not unobserving: Forgetting to unobserve elements that no longer need tracking
- Complex callbacks: Performing heavy DOM operations in the callback function
- Too many thresholds: Using many thresholds when fewer would suffice
Measuring Performance
// Performance measurement example
console.time('Observer Setup');
const elements = document.querySelectorAll('.tracked-element');
const observer = new IntersectionObserver((entries) => {
// Measure callback execution time
console.time('Observer Callback');
entries.forEach(entry => {
if (entry.isIntersecting) {
// Your logic here
}
});
console.timeEnd('Observer Callback');
}, { threshold: 0.1 });
elements.forEach(element => observer.observe(element));
console.timeEnd('Observer Setup');
Browser Support and Polyfills
Browser Support
Intersection Observer has good support in modern browsers:
- Chrome: 51+
- Firefox: 55+
- Safari: 12.1+
- Edge: 15+
Using a Polyfill
For older browsers, you can use the Intersection Observer polyfill:
// Check if Intersection Observer is supported
if (!('IntersectionObserver' in window)) {
// Load polyfill
const script = document.createElement('script');
script.src = 'https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver';
document.head.appendChild(script);
script.onload = () => {
// Initialize your observers once the polyfill is loaded
initializeObservers();
};
} else {
// Browser supports Intersection Observer natively
initializeObservers();
}
function initializeObservers() {
// Your Intersection Observer code here
}
Feature Detection
Always use feature detection to provide fallbacks for browsers without support:
// Lazy loading with fallback
function initializeLazyLoading() {
const lazyImages = document.querySelectorAll('.lazy-image');
if ('IntersectionObserver' in window) {
// Use Intersection Observer
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyImages.forEach(image => imageObserver.observe(image));
} else {
// Fallback for browsers without support
// Load all images immediately
lazyImages.forEach(image => loadImage(image));
}
}
function loadImage(img) {
if (img.dataset.src) {
img.src = img.dataset.src;
img.classList.add('loaded');
}
}
Resources and Further Reading
Documentation
- MDN: Intersection Observer API
- W3C: Intersection Observer Specification
- Google Developers: IntersectionObserver's Coming into View
Tools and Libraries
Tutorials and Articles
- CSS-Tricks: Functional Uses for Intersection Observer
- web.dev: Trust is Good, Observation is Better—Intersection Observer v2
- Smashing Magazine: Lazy Loading with Intersection Observer