JavaScript Intersection Observer API: Efficient Element Visibility Detection
Master the Intersection Observer API for performant scroll-based animations, lazy loading, and visibility tracking. Learn practical patterns and best practices.
The Intersection Observer API provides a performant way to asynchronously observe changes in the intersection of a target element with an ancestor element or the viewport. It's perfect for implementing lazy loading, infinite scrolling, and triggering animations based on scroll position.
Understanding Intersection Observer
Intersection Observer allows you to configure a callback that is fired whenever a target element intersects with the root element or viewport.
Basic Intersection Observer Setup
// Create an observer
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('Element is visible:', entry.target);
// Optionally unobserve after first intersection
observer.unobserve(entry.target);
} else {
console.log('Element is not visible:', entry.target);
}
});
});
// Observe an element
const targetElement = document.querySelector('.target');
observer.observe(targetElement);
// Observe multiple elements
const elements = document.querySelectorAll('.observe-me');
elements.forEach((element) => {
observer.observe(element);
});
// Stop observing
observer.unobserve(targetElement);
// Stop observing all elements
observer.disconnect();
// Understanding the entry object
const detailedObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log({
target: entry.target, // The observed element
isIntersecting: entry.isIntersecting, // Is it visible?
intersectionRatio: entry.intersectionRatio, // How much is visible (0-1)
boundingClientRect: entry.boundingClientRect, // Element's position
intersectionRect: entry.intersectionRect, // Visible portion
rootBounds: entry.rootBounds, // Root element bounds
time: entry.time, // When it happened
});
});
});
Observer Options
// Configuration options
const options = {
// The element that is used as the viewport
root: document.querySelector('.scroll-container'), // null = viewport
// Margin around the root
rootMargin: '50px 0px -100px 0px', // top right bottom left
// Threshold(s) at which to trigger callback
threshold: 0.5, // 50% visible
};
const observer = new IntersectionObserver(callback, options);
// Multiple thresholds
const multiThresholdOptions = {
root: null,
rootMargin: '0px',
threshold: [0, 0.25, 0.5, 0.75, 1], // Triggers at 0%, 25%, 50%, 75%, 100%
};
const multiObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const percentage = Math.round(entry.intersectionRatio * 100);
console.log(`${percentage}% visible`);
// Different actions based on visibility
if (entry.intersectionRatio >= 0.75) {
entry.target.classList.add('mostly-visible');
} else if (entry.intersectionRatio >= 0.25) {
entry.target.classList.add('partially-visible');
} else {
entry.target.classList.remove('mostly-visible', 'partially-visible');
}
});
}, multiThresholdOptions);
// Dynamic root margin
function createDynamicObserver() {
// Calculate margin based on viewport
const viewportHeight = window.innerHeight;
const margin = Math.floor(viewportHeight * 0.2); // 20% of viewport
return new IntersectionObserver(callback, {
rootMargin: `${margin}px 0px`,
});
}
// Negative root margin (trigger inside viewport)
const insideObserver = new IntersectionObserver(callback, {
rootMargin: '-10% 0px -10% 0px', // 10% inside viewport on all sides
});
Practical Applications
Lazy Loading Images
// Basic lazy loading
class LazyImageLoader {
constructor() {
this.imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.imageObserver.unobserve(entry.target);
}
});
},
{
rootMargin: '50px 0px', // Start loading 50px before visible
threshold: 0.01,
}
);
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (!src) return;
// Create a new image to load
const newImg = new Image();
newImg.onload = () => {
img.src = src;
if (srcset) {
img.srcset = srcset;
}
img.classList.add('loaded');
// Remove data attributes
delete img.dataset.src;
delete img.dataset.srcset;
};
newImg.onerror = () => {
img.classList.add('error');
};
// Start loading
newImg.src = src;
}
observe(selector = 'img[data-src]') {
const images = document.querySelectorAll(selector);
images.forEach((img) => this.imageObserver.observe(img));
}
disconnect() {
this.imageObserver.disconnect();
}
}
// Advanced lazy loading with placeholder
class AdvancedLazyLoader {
constructor(options = {}) {
this.options = {
rootMargin: '100px',
threshold: 0,
loadingClass: 'loading',
loadedClass: 'loaded',
errorClass: 'error',
...options,
};
this.observer = this.createObserver();
this.loadingImages = new Map();
}
createObserver() {
return new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadMedia(entry.target);
}
});
},
{
rootMargin: this.options.rootMargin,
threshold: this.options.threshold,
}
);
}
loadMedia(element) {
const src = element.dataset.src;
const srcset = element.dataset.srcset;
const sizes = element.dataset.sizes;
if (!src && !srcset) return;
// Check if already loading
if (this.loadingImages.has(element)) return;
element.classList.add(this.options.loadingClass);
if (element.tagName === 'IMG') {
this.loadImage(element, src, srcset, sizes);
} else if (element.tagName === 'VIDEO') {
this.loadVideo(element, src);
} else {
// Background image
this.loadBackgroundImage(element, src);
}
}
loadImage(img, src, srcset, sizes) {
const tempImg = new Image();
this.loadingImages.set(img, tempImg);
tempImg.onload = () => {
img.src = src || img.src;
if (srcset) img.srcset = srcset;
if (sizes) img.sizes = sizes;
this.onLoadComplete(img);
};
tempImg.onerror = () => this.onLoadError(img);
// Set sources to start loading
if (srcset) tempImg.srcset = srcset;
tempImg.src = src;
}
loadBackgroundImage(element, src) {
const tempImg = new Image();
this.loadingImages.set(element, tempImg);
tempImg.onload = () => {
element.style.backgroundImage = `url('${src}')`;
this.onLoadComplete(element);
};
tempImg.onerror = () => this.onLoadError(element);
tempImg.src = src;
}
loadVideo(video, src) {
video.src = src;
video.load();
video.onloadeddata = () => this.onLoadComplete(video);
video.onerror = () => this.onLoadError(video);
}
onLoadComplete(element) {
element.classList.remove(this.options.loadingClass);
element.classList.add(this.options.loadedClass);
this.loadingImages.delete(element);
this.observer.unobserve(element);
// Clean up data attributes
delete element.dataset.src;
delete element.dataset.srcset;
delete element.dataset.sizes;
// Dispatch custom event
element.dispatchEvent(
new CustomEvent('lazyloaded', {
bubbles: true,
detail: { element },
})
);
}
onLoadError(element) {
element.classList.remove(this.options.loadingClass);
element.classList.add(this.options.errorClass);
this.loadingImages.delete(element);
this.observer.unobserve(element);
// Dispatch error event
element.dispatchEvent(
new CustomEvent('lazyloaderror', {
bubbles: true,
detail: { element },
})
);
}
observe(selector = '[data-src]') {
const elements = document.querySelectorAll(selector);
elements.forEach((el) => this.observer.observe(el));
}
refresh() {
this.observe();
}
destroy() {
this.observer.disconnect();
this.loadingImages.clear();
}
}
// Usage
const lazyLoader = new AdvancedLazyLoader({
rootMargin: '200px',
loadingClass: 'is-loading',
loadedClass: 'is-loaded',
});
lazyLoader.observe();
// Listen for new elements
document.addEventListener('DOMContentLoaded', () => {
lazyLoader.observe();
});
Infinite Scrolling
// Infinite scroll implementation
class InfiniteScroll {
constructor(options = {}) {
this.options = {
root: null,
rootMargin: '100px',
threshold: 0,
loadMoreFn: async () => {},
container: '.items-container',
sentinel: '.scroll-sentinel',
...options,
};
this.isLoading = false;
this.hasMore = true;
this.page = 1;
this.observer = this.createObserver();
this.observeSentinel();
}
createObserver() {
return new IntersectionObserver(
async (entries) => {
const entry = entries[0];
if (entry.isIntersecting && !this.isLoading && this.hasMore) {
await this.loadMore();
}
},
{
root: this.options.root,
rootMargin: this.options.rootMargin,
threshold: this.options.threshold,
}
);
}
async loadMore() {
this.isLoading = true;
this.showLoader();
try {
const result = await this.options.loadMoreFn(this.page);
if (result.items && result.items.length > 0) {
this.appendItems(result.items);
this.page++;
this.hasMore = result.hasMore !== false;
} else {
this.hasMore = false;
}
if (!this.hasMore) {
this.showEndMessage();
this.observer.disconnect();
}
} catch (error) {
console.error('Error loading more items:', error);
this.showError();
} finally {
this.isLoading = false;
this.hideLoader();
}
}
appendItems(items) {
const container = document.querySelector(this.options.container);
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const element = this.createItemElement(item);
fragment.appendChild(element);
});
container.appendChild(fragment);
// Dispatch event
container.dispatchEvent(
new CustomEvent('itemsloaded', {
detail: { items, page: this.page },
})
);
}
createItemElement(item) {
const div = document.createElement('div');
div.className = 'item';
div.innerHTML = `
<h3>${item.title}</h3>
<p>${item.description}</p>
`;
return div;
}
observeSentinel() {
const sentinel = document.querySelector(this.options.sentinel);
if (sentinel) {
this.observer.observe(sentinel);
}
}
showLoader() {
const loader = document.querySelector('.infinite-scroll-loader');
if (loader) loader.style.display = 'block';
}
hideLoader() {
const loader = document.querySelector('.infinite-scroll-loader');
if (loader) loader.style.display = 'none';
}
showEndMessage() {
const message = document.querySelector('.infinite-scroll-end');
if (message) message.style.display = 'block';
}
showError() {
const error = document.querySelector('.infinite-scroll-error');
if (error) error.style.display = 'block';
}
reset() {
this.page = 1;
this.hasMore = true;
this.isLoading = false;
const container = document.querySelector(this.options.container);
container.innerHTML = '';
this.hideLoader();
this.observeSentinel();
}
destroy() {
this.observer.disconnect();
}
}
// Virtual scrolling with Intersection Observer
class VirtualScroll {
constructor(options = {}) {
this.options = {
itemHeight: 50,
buffer: 5,
container: '.virtual-scroll-container',
...options,
};
this.items = [];
this.visibleRange = { start: 0, end: 0 };
this.observer = null;
this.init();
}
init() {
this.container = document.querySelector(this.options.container);
this.viewport = this.container.querySelector('.viewport');
this.content = this.container.querySelector('.content');
this.createObserver();
this.setupScrollHandler();
}
createObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const index = parseInt(entry.target.dataset.index);
if (entry.isIntersecting) {
this.renderItem(entry.target, this.items[index]);
} else {
this.recycleItem(entry.target);
}
});
},
{
root: this.viewport,
rootMargin: `${this.options.buffer * this.options.itemHeight}px 0px`,
}
);
}
setItems(items) {
this.items = items;
this.render();
}
render() {
// Set content height
const totalHeight = this.items.length * this.options.itemHeight;
this.content.style.height = `${totalHeight}px`;
// Calculate visible range
const scrollTop = this.viewport.scrollTop;
const viewportHeight = this.viewport.clientHeight;
const startIndex = Math.floor(scrollTop / this.options.itemHeight);
const endIndex = Math.ceil(
(scrollTop + viewportHeight) / this.options.itemHeight
);
this.visibleRange = {
start: Math.max(0, startIndex - this.options.buffer),
end: Math.min(this.items.length - 1, endIndex + this.options.buffer),
};
this.renderVisibleItems();
}
renderVisibleItems() {
const fragment = document.createDocumentFragment();
for (let i = this.visibleRange.start; i <= this.visibleRange.end; i++) {
const item = this.createItemElement(i);
fragment.appendChild(item);
this.observer.observe(item);
}
this.content.innerHTML = '';
this.content.appendChild(fragment);
}
createItemElement(index) {
const div = document.createElement('div');
div.className = 'virtual-item';
div.dataset.index = index;
div.style.position = 'absolute';
div.style.top = `${index * this.options.itemHeight}px`;
div.style.height = `${this.options.itemHeight}px`;
return div;
}
renderItem(element, data) {
element.innerHTML = `<div class="item-content">${data.content}</div>`;
element.classList.add('rendered');
}
recycleItem(element) {
element.innerHTML = '';
element.classList.remove('rendered');
}
setupScrollHandler() {
let scrollTimeout;
this.viewport.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => this.render(), 16);
});
}
destroy() {
this.observer.disconnect();
}
}
Scroll-Based Animations
// Scroll animation manager
class ScrollAnimationManager {
constructor(options = {}) {
this.options = {
animationClass: 'animate',
threshold: 0.1,
rootMargin: '0px',
once: true,
...options,
};
this.animations = new Map();
this.observer = this.createObserver();
}
createObserver() {
return new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const element = entry.target;
const animation = this.animations.get(element);
if (!animation) return;
if (entry.isIntersecting) {
this.playAnimation(element, animation, entry.intersectionRatio);
if (this.options.once) {
this.observer.unobserve(element);
this.animations.delete(element);
}
} else if (!this.options.once) {
this.resetAnimation(element, animation);
}
});
},
{
threshold: this.options.threshold,
rootMargin: this.options.rootMargin,
}
);
}
addAnimation(element, animation) {
this.animations.set(element, animation);
this.observer.observe(element);
// Set initial state
if (animation.from) {
Object.assign(element.style, animation.from);
}
}
playAnimation(element, animation, ratio) {
if (animation.onEnter) {
animation.onEnter(element, ratio);
}
if (animation.to) {
element.style.transition = animation.transition || 'all 0.6s ease-out';
Object.assign(element.style, animation.to);
}
if (animation.className) {
element.classList.add(animation.className);
}
}
resetAnimation(element, animation) {
if (animation.onLeave) {
animation.onLeave(element);
}
if (animation.from) {
Object.assign(element.style, animation.from);
}
if (animation.className) {
element.classList.remove(animation.className);
}
}
// Predefined animations
fadeIn(element, options = {}) {
this.addAnimation(element, {
from: { opacity: '0' },
to: { opacity: '1' },
transition: 'opacity 0.6s ease-out',
...options,
});
}
slideInLeft(element, options = {}) {
this.addAnimation(element, {
from: {
transform: 'translateX(-100px)',
opacity: '0',
},
to: {
transform: 'translateX(0)',
opacity: '1',
},
transition: 'all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
...options,
});
}
slideInRight(element, options = {}) {
this.addAnimation(element, {
from: {
transform: 'translateX(100px)',
opacity: '0',
},
to: {
transform: 'translateX(0)',
opacity: '1',
},
transition: 'all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
...options,
});
}
scaleIn(element, options = {}) {
this.addAnimation(element, {
from: {
transform: 'scale(0.8)',
opacity: '0',
},
to: {
transform: 'scale(1)',
opacity: '1',
},
transition: 'all 0.6s ease-out',
...options,
});
}
// Parallax effect
parallax(element, options = {}) {
const config = {
speed: 0.5,
offset: 0,
...options,
};
this.addAnimation(element, {
onEnter: (el, ratio) => {
const scrolled = window.pageYOffset;
const rate = scrolled * config.speed * -1;
el.style.transform = `translateY(${rate + config.offset}px)`;
},
onLeave: (el) => {
el.style.transform = '';
},
});
}
// Stagger animations
staggerAnimation(elements, animation, delay = 100) {
elements.forEach((element, index) => {
setTimeout(() => {
this.addAnimation(element, animation);
}, index * delay);
});
}
destroy() {
this.observer.disconnect();
this.animations.clear();
}
}
// Progress indicator based on scroll
class ScrollProgress {
constructor(options = {}) {
this.options = {
sections: '.section',
indicators: '.progress-indicator',
activeClass: 'active',
threshold: [0, 0.25, 0.5, 0.75, 1],
...options,
};
this.sections = [];
this.observer = this.createObserver();
this.init();
}
createObserver() {
return new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const index = this.sections.indexOf(entry.target);
const indicator = document.querySelectorAll(this.options.indicators)[
index
];
if (!indicator) return;
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
this.setActiveIndicator(indicator);
}
// Update progress bar
const progress = Math.round(entry.intersectionRatio * 100);
indicator.style.setProperty('--progress', `${progress}%`);
});
},
{
threshold: this.options.threshold,
}
);
}
init() {
const sections = document.querySelectorAll(this.options.sections);
sections.forEach((section) => {
this.sections.push(section);
this.observer.observe(section);
});
}
setActiveIndicator(indicator) {
// Remove active class from all
document.querySelectorAll(this.options.indicators).forEach((ind) => {
ind.classList.remove(this.options.activeClass);
});
// Add active class to current
indicator.classList.add(this.options.activeClass);
}
}
Performance Monitoring
// Performance monitoring with Intersection Observer
class VisibilityTracker {
constructor() {
this.trackedElements = new Map();
this.visibilityData = new Map();
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
this.updateVisibility(entry);
});
},
{
threshold: [0, 0.25, 0.5, 0.75, 1],
}
);
}
track(element, metadata = {}) {
const data = {
element,
metadata,
firstView: null,
totalViewTime: 0,
viewCount: 0,
lastViewStart: null,
isVisible: false,
maxVisibility: 0,
};
this.trackedElements.set(element, data);
this.observer.observe(element);
}
updateVisibility(entry) {
const data = this.trackedElements.get(entry.target);
if (!data) return;
const now = Date.now();
if (entry.isIntersecting) {
if (!data.isVisible) {
// Element became visible
data.isVisible = true;
data.lastViewStart = now;
data.viewCount++;
if (!data.firstView) {
data.firstView = now;
this.onFirstView(data);
}
this.onViewStart(data);
}
// Update max visibility
data.maxVisibility = Math.max(
data.maxVisibility,
entry.intersectionRatio
);
// Trigger visibility milestones
if (entry.intersectionRatio >= 0.5 && !data.halfViewed) {
data.halfViewed = true;
this.onHalfView(data);
}
if (entry.intersectionRatio >= 1 && !data.fullyViewed) {
data.fullyViewed = true;
this.onFullView(data);
}
} else {
if (data.isVisible) {
// Element became hidden
data.isVisible = false;
const viewDuration = now - data.lastViewStart;
data.totalViewTime += viewDuration;
this.onViewEnd(data, viewDuration);
}
}
}
onFirstView(data) {
console.log('First view:', data.metadata);
// Send analytics event
this.sendAnalytics('first_view', data);
}
onViewStart(data) {
console.log('View started:', data.metadata);
}
onViewEnd(data, duration) {
console.log('View ended:', data.metadata, 'Duration:', duration);
// Send view duration analytics
this.sendAnalytics('view_duration', { ...data, duration });
}
onHalfView(data) {
console.log('50% viewed:', data.metadata);
this.sendAnalytics('half_view', data);
}
onFullView(data) {
console.log('100% viewed:', data.metadata);
this.sendAnalytics('full_view', data);
}
sendAnalytics(event, data) {
// Send to analytics service
if (typeof gtag !== 'undefined') {
gtag('event', event, {
element_id: data.metadata.id,
element_type: data.metadata.type,
view_count: data.viewCount,
total_view_time: data.totalViewTime,
});
}
}
getReport() {
const report = [];
this.trackedElements.forEach((data, element) => {
report.push({
element: element.id || element.className,
metadata: data.metadata,
firstView: data.firstView,
totalViewTime: data.totalViewTime,
viewCount: data.viewCount,
maxVisibility: Math.round(data.maxVisibility * 100) + '%',
currentlyVisible: data.isVisible,
});
});
return report;
}
destroy() {
// Calculate final view times
this.trackedElements.forEach((data, element) => {
if (data.isVisible) {
const now = Date.now();
const viewDuration = now - data.lastViewStart;
data.totalViewTime += viewDuration;
this.onViewEnd(data, viewDuration);
}
});
this.observer.disconnect();
this.trackedElements.clear();
}
}
// Ad viewability tracking
class AdViewability {
constructor(options = {}) {
this.options = {
viewableThreshold: 0.5, // 50% visible
viewableTime: 1000, // 1 second
...options,
};
this.ads = new Map();
this.observer = this.createObserver();
}
createObserver() {
return new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
this.updateAdViewability(entry);
});
},
{
threshold: [0, this.options.viewableThreshold, 1],
}
);
}
trackAd(element, adData) {
const tracking = {
element,
adData,
viewable: false,
viewableStart: null,
totalViewableTime: 0,
impressionSent: false,
viewabilityTimer: null,
};
this.ads.set(element, tracking);
this.observer.observe(element);
}
updateAdViewability(entry) {
const tracking = this.ads.get(entry.target);
if (!tracking) return;
const isViewable =
entry.isIntersecting &&
entry.intersectionRatio >= this.options.viewableThreshold;
if (isViewable && !tracking.viewable) {
// Ad became viewable
tracking.viewable = true;
tracking.viewableStart = Date.now();
// Start viewability timer
tracking.viewabilityTimer = setTimeout(() => {
if (!tracking.impressionSent) {
tracking.impressionSent = true;
this.recordImpression(tracking);
}
}, this.options.viewableTime);
} else if (!isViewable && tracking.viewable) {
// Ad no longer viewable
tracking.viewable = false;
const viewDuration = Date.now() - tracking.viewableStart;
tracking.totalViewableTime += viewDuration;
// Clear timer if impression not yet sent
if (tracking.viewabilityTimer) {
clearTimeout(tracking.viewabilityTimer);
tracking.viewabilityTimer = null;
}
}
}
recordImpression(tracking) {
console.log('Viewable impression:', tracking.adData);
// Send impression event
fetch('/api/ad-impression', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
adId: tracking.adData.id,
timestamp: Date.now(),
viewableTime: this.options.viewableTime,
}),
});
}
getAdMetrics(element) {
const tracking = this.ads.get(element);
if (!tracking) return null;
return {
adId: tracking.adData.id,
viewable: tracking.viewable,
totalViewableTime: tracking.totalViewableTime,
impressionSent: tracking.impressionSent,
};
}
}
Advanced Patterns
Dynamic Observer Management
// Observer pool for performance
class ObserverPool {
constructor() {
this.observers = new Map();
this.elementMap = new WeakMap();
}
getObserver(options = {}) {
const key = this.getOptionsKey(options);
if (!this.observers.has(key)) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const callbacks = this.elementMap.get(entry.target);
if (callbacks) {
callbacks.forEach((callback) => callback(entry));
}
});
}, options);
this.observers.set(key, observer);
}
return this.observers.get(key);
}
observe(element, callback, options = {}) {
const observer = this.getObserver(options);
// Store callback
if (!this.elementMap.has(element)) {
this.elementMap.set(element, new Set());
}
this.elementMap.get(element).add(callback);
// Start observing
observer.observe(element);
// Return unobserve function
return () => this.unobserve(element, callback, options);
}
unobserve(element, callback, options = {}) {
const callbacks = this.elementMap.get(element);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.elementMap.delete(element);
const observer = this.getObserver(options);
observer.unobserve(element);
}
}
}
getOptionsKey(options) {
return JSON.stringify({
root: options.root ? 'custom' : 'viewport',
rootMargin: options.rootMargin || '0px',
threshold: options.threshold || 0,
});
}
disconnect() {
this.observers.forEach((observer) => observer.disconnect());
this.observers.clear();
}
}
// Reactive intersection observer
class ReactiveIntersectionObserver {
constructor(options = {}) {
this.options = options;
this.observer = null;
this.subscriptions = new Map();
this.init();
}
init() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const subscription = this.subscriptions.get(entry.target);
if (subscription) {
subscription.next(entry);
}
});
}, this.options);
}
observe(element) {
const subscription = {
observers: [],
next(entry) {
this.observers.forEach((observer) => observer(entry));
},
subscribe(callback) {
this.observers.push(callback);
return () => {
const index = this.observers.indexOf(callback);
if (index > -1) {
this.observers.splice(index, 1);
}
};
},
};
this.subscriptions.set(element, subscription);
this.observer.observe(element);
return subscription;
}
unobserve(element) {
this.subscriptions.delete(element);
this.observer.unobserve(element);
}
disconnect() {
this.observer.disconnect();
this.subscriptions.clear();
}
}
Custom Hooks for Frameworks
// React hook example
function useIntersectionObserver(options = {}) {
const [entries, setEntries] = React.useState([]);
const [observer, setObserver] = React.useState(null);
React.useEffect(() => {
const obs = new IntersectionObserver((entries) => {
setEntries(entries);
}, options);
setObserver(obs);
return () => obs.disconnect();
}, [options.root, options.rootMargin, options.threshold]);
const observe = React.useCallback(
(element) => {
if (observer && element) {
observer.observe(element);
}
},
[observer]
);
const unobserve = React.useCallback(
(element) => {
if (observer && element) {
observer.unobserve(element);
}
},
[observer]
);
return { entries, observe, unobserve };
}
// Vue 3 composable
function useIntersectionObserver(options = {}) {
const entries = Vue.ref([]);
const observer = Vue.ref(null);
Vue.onMounted(() => {
observer.value = new IntersectionObserver((newEntries) => {
entries.value = newEntries;
}, options);
});
Vue.onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
});
const observe = (element) => {
if (observer.value && element) {
observer.value.observe(element);
}
};
const unobserve = (element) => {
if (observer.value && element) {
observer.value.unobserve(element);
}
};
return { entries, observe, unobserve };
}
Best Practices
- Reuse observers when possible
// Good - single observer for multiple elements
const observer = new IntersectionObserver(callback);
elements.forEach((el) => observer.observe(el));
// Less efficient - observer per element
elements.forEach((el) => {
const observer = new IntersectionObserver(callback);
observer.observe(el);
});
- Unobserve elements when no longer needed
// Good - clean up after use
if (entry.isIntersecting) {
loadContent(entry.target);
observer.unobserve(entry.target);
}
- Use appropriate thresholds
// For lazy loading - small threshold
{
threshold: 0.01;
}
// For analytics - multiple thresholds
{
threshold: [0, 0.25, 0.5, 0.75, 1];
}
// For animations - single threshold
{
threshold: 0.1;
}
- Consider rootMargin for better UX
// Load images before they're visible
{
rootMargin: '200px';
}
// Trigger animations when element is well in view
{
rootMargin: '-50px';
}
Conclusion
The Intersection Observer API is a powerful tool for efficiently detecting element visibility. It enables performant implementations of common patterns like lazy loading, infinite scrolling, and scroll-based animations without the performance overhead of scroll event listeners. By understanding its options and patterns, you can create smooth, efficient user experiences that scale well even with hundreds of observed elements.