IntersectionObserver and ResizeObserver
1. IntersectionObserver (new IntersectionObserver, observe)
| Method/Property | Description | Browser Support |
|---|---|---|
| new IntersectionObserver(callback, options) | Creates intersection observer. Callback receives IntersectionObserverEntry[]. |
All Modern Browsers |
| observer.observe(element) | Start observing element for intersection changes. | All Modern Browsers |
| observer.unobserve(element) | Stop observing specific element. | All Modern Browsers |
| observer.disconnect() | Stop observing all elements. | All Modern Browsers |
| observer.takeRecords() | Returns array of queued entries and clears queue. | All Modern Browsers |
| IntersectionObserverEntry Property | Type | Description |
|---|---|---|
| target | Element |
The observed element. |
| isIntersecting | boolean |
true if element is intersecting root/viewport. |
| intersectionRatio | number |
Ratio of intersection (0.0 to 1.0). 1.0 = fully visible. |
| intersectionRect | DOMRectReadOnly |
Bounding rect of visible portion of element. |
| boundingClientRect | DOMRectReadOnly |
Bounding rect of entire element. |
| rootBounds | DOMRectReadOnly |
Bounding rect of root element (or viewport if root is null). |
| time | number |
Timestamp when intersection change occurred. |
Example: Basic intersection observer
// Create intersection observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log("Element:", entry.target);
console.log("Is intersecting:", entry.isIntersecting);
console.log("Intersection ratio:", entry.intersectionRatio);
if (entry.isIntersecting) {
// Element entered viewport
entry.target.classList.add("visible");
console.log("Element became visible");
} else {
// Element left viewport
entry.target.classList.remove("visible");
console.log("Element became hidden");
}
});
});
// Observe elements
const elements = document.querySelectorAll(".observe-me");
elements.forEach(el => observer.observe(el));
// Unobserve specific element
observer.unobserve(elements[0]);
// Disconnect all
observer.disconnect();
// One-time visibility check
const oneTimeObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Do something once
console.log("Element appeared!");
// Stop observing after first intersection
obs.unobserve(entry.target);
}
});
});
const heroElement = document.querySelector(".hero");
oneTimeObserver.observe(heroElement);
Example: Trigger animations on visibility
// Animate elements when they enter viewport
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Add animation class
entry.target.classList.add("animate-in");
// Optional: stop observing after animation
animateObserver.unobserve(entry.target);
}
});
});
// Observe all elements with data-animate attribute
document.querySelectorAll("[data-animate]").forEach(el => {
animateObserver.observe(el);
});
// Track visibility percentage
const percentageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const percent = Math.round(entry.intersectionRatio * 100);
entry.target.textContent = `Visible: ${percent}%`;
// Trigger at different thresholds
if (percent >= 75) {
entry.target.style.background = "green";
} else if (percent >= 50) {
entry.target.style.background = "yellow";
} else if (percent > 0) {
entry.target.style.background = "red";
} else {
entry.target.style.background = "gray";
}
});
}, { "threshold": [0, 0.25, 0.5, 0.75, 1.0] });
const trackedElement = document.querySelector(".tracked");
percentageObserver.observe(trackedElement);
Note: Intersection Observer is asynchronous and efficient - much better than scroll events. Callback fires when intersection changes, not continuously. Use
isIntersecting for simple visibility checks, intersectionRatio for percentage visible.
2. Intersection Options (root, rootMargin, threshold)
| Option | Type | Description | Default |
|---|---|---|---|
| root | Element | null |
Element to use as viewport. null = browser viewport. |
null |
| rootMargin | string |
Margin around root (CSS syntax). Can be negative. Example: "50px 0px -50px 0px". | "0px" |
| threshold | number | number[] |
Intersection ratio(s) to trigger callback. 0.0 to 1.0. Can be array for multiple thresholds. | 0 |
| Threshold Value | Meaning | Use Case |
|---|---|---|
| 0 | Callback fires when any pixel is visible. | Detect element entering/leaving viewport |
| 0.5 | Callback fires when 50% of element is visible. | Trigger when element half-visible |
| 1.0 | Callback fires when 100% of element is visible. | Trigger when element fully-visible |
| [0, 0.25, 0.5, 0.75, 1.0] | Callback fires at 0%, 25%, 50%, 75%, 100% visibility. | Track visibility percentage changes |
Example: Root margin and thresholds
// Preload images before they enter viewport (positive rootMargin)
const preloadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Load image 200px before it enters viewport
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
preloadObserver.unobserve(img);
}
});
}, {
"rootMargin": "200px" // Expand viewport by 200px on all sides
});
document.querySelectorAll("img[data-src]").forEach(img => {
preloadObserver.observe(img);
});
// Trigger only when element fully visible
const fullyVisibleObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio === 1.0) {
console.log("Element 100% visible:", entry.target);
entry.target.classList.add("fully-visible");
} else {
entry.target.classList.remove("fully-visible");
}
});
}, {
"threshold": 1.0
});
// Negative rootMargin to shrink viewport
const stickyHeaderObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Trigger 80px before element reaches top
if (!entry.isIntersecting) {
document.body.classList.add("sticky-header");
} else {
document.body.classList.remove("sticky-header");
}
});
}, {
"rootMargin": "-80px 0px 0px 0px" // Shrink top by 80px
});
const sentinelElement = document.querySelector(".sentinel");
stickyHeaderObserver.observe(sentinelElement);
// Multiple thresholds for progress tracking
const progressObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const percent = Math.round(entry.intersectionRatio * 100);
console.log(`Element ${percent}% visible`);
// Update progress bar
entry.target.querySelector(".progress").style.width = `${percent}%`;
});
}, {
"threshold": Array.from({ "length": 101 }, (_, i) => i / 100) // 0, 0.01, 0.02, ..., 1.0
});
// Custom root container
const scrollContainer = document.querySelector(".scroll-container");
const containerObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log("Visible in container:", entry.isIntersecting);
});
}, {
"root": scrollContainer, // Use custom container instead of viewport
"threshold": 0.5
});
const containerItem = scrollContainer.querySelector(".item");
containerObserver.observe(containerItem);
Note:
rootMargin uses CSS margin syntax - can be 1, 2, or 4 values. Positive values expand viewport (preload), negative shrink (trigger earlier). threshold can be single number or array. More thresholds = more callback invocations.
Warning: Don't use too many thresholds (e.g., 100+ values) - impacts performance.
threshold: 1.0 may never trigger if element larger than viewport. Use rootMargin carefully with negative values - can cause immediate firing.
3. ResizeObserver (new ResizeObserver, observe)
| Method/Property | Description | Browser Support |
|---|---|---|
| new ResizeObserver(callback) | Creates resize observer. Callback receives ResizeObserverEntry[]. |
All Modern Browsers |
| observer.observe(element, options) | Start observing element size. Options: box ("content-box", "border-box", "device-pixel-content-box"). |
All Modern Browsers |
| observer.unobserve(element) | Stop observing specific element. | All Modern Browsers |
| observer.disconnect() | Stop observing all elements. | All Modern Browsers |
| ResizeObserverEntry Property | Type | Description |
|---|---|---|
| target | Element |
The observed element. |
| contentRect | DOMRectReadOnly |
Content box dimensions (excludes padding, border, scrollbar). |
| borderBoxSize | ResizeObserverSize[] |
Border box dimensions (includes padding and border). Array for multi-column. |
| contentBoxSize | ResizeObserverSize[] |
Content box dimensions. Array for multi-column. |
| devicePixelContentBoxSize | ResizeObserverSize[] |
Content box in device pixels (for high-DPI displays). |
| ResizeObserverSize Property | Type | Description |
|---|---|---|
| inlineSize | number |
Inline size (width in horizontal writing mode). |
| blockSize | number |
Block size (height in horizontal writing mode). |
Example: Basic resize observer
// Observe element size changes
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
// Legacy way (deprecated but widely supported)
const { "width": width, "height": height } = entry.contentRect;
console.log(`Content size: ${width}x${height}`);
// Modern way (preferred)
if (entry.contentBoxSize) {
// contentBoxSize is array (for multi-column)
const contentBoxSize = entry.contentBoxSize[0];
console.log(`Inline size: ${contentBoxSize.inlineSize}px`);
console.log(`Block size: ${contentBoxSize.blockSize}px`);
}
// Border box size (includes padding and border)
if (entry.borderBoxSize) {
const borderBoxSize = entry.borderBoxSize[0];
console.log(`Border box: ${borderBoxSize.inlineSize}x${borderBoxSize.blockSize}`);
}
// React to size changes
const element = entry.target;
if (contentBoxSize.inlineSize < 400) {
element.classList.add("small");
element.classList.remove("large");
} else {
element.classList.add("large");
element.classList.remove("small");
}
});
});
// Observe element
const box = document.querySelector(".resizable-box");
resizeObserver.observe(box);
// Observe with specific box model
resizeObserver.observe(box, { "box": "border-box" });
// Stop observing
resizeObserver.unobserve(box);
resizeObserver.disconnect();
Example: Responsive component behavior
// Container queries with ResizeObserver
class ResponsiveComponent {
constructor(element) {
this.element = element;
this.observer = new ResizeObserver(this.handleResize.bind(this));
this.observer.observe(this.element);
}
handleResize(entries) {
const entry = entries[0];
const width = entry.contentBoxSize[0].inlineSize;
// Remove all size classes
this.element.classList.remove("size-sm", "size-md", "size-lg", "size-xl");
// Add appropriate class based on width
if (width < 400) {
this.element.classList.add("size-sm");
} else if (width < 768) {
this.element.classList.add("size-md");
} else if (width < 1024) {
this.element.classList.add("size-lg");
} else {
this.element.classList.add("size-xl");
}
// Dispatch custom event
this.element.dispatchEvent(new CustomEvent("sizechange", {
"detail": { "width": width, "breakpoint": this.getBreakpoint(width) }
}));
}
getBreakpoint(width) {
if (width < 400) return "sm";
if (width < 768) return "md";
if (width < 1024) return "lg";
return "xl";
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const component = new ResponsiveComponent(document.querySelector(".component"));
component.element.addEventListener("sizechange", (e) => {
console.log(`Breakpoint: ${e.detail.breakpoint}, Width: ${e.detail.width}px`);
});
// Sync chart size with container
const chartContainer = document.querySelector(".chart-container");
const chartObserver = new ResizeObserver((entries) => {
const entry = entries[0];
const { "inlineSize": width, "blockSize": height } = entry.contentBoxSize[0];
// Resize chart to match container
myChart.resize(width, height);
});
chartObserver.observe(chartContainer);
Note: Resize Observer is much more efficient than window resize events or polling. Fires when element size changes due to any reason (CSS, content, viewport). Use
contentBoxSize array (modern) instead of contentRect (legacy).
Warning: Avoid infinite loops - don't resize observed element inside callback (can cause circular updates). ResizeObserver fires frequently - debounce expensive operations. Disconnect observers when component unmounts to prevent memory leaks.
4. Observer Performance Patterns
| Best Practice | Why | Implementation |
|---|---|---|
| Single observer instance | More efficient than multiple observers for same purpose. | Reuse one observer for all similar elements. |
| Unobserve after use | Reduces overhead for one-time observations. | Call unobserve() after first trigger. |
| Disconnect on cleanup | Prevents memory leaks in SPAs. | Call disconnect() when component unmounts. |
| Lightweight callbacks | Observers fire frequently - expensive callbacks cause jank. | Keep callback logic minimal, defer heavy work. |
| Debounce expensive operations | ResizeObserver can fire many times per second. | Use debounce/throttle for expensive updates. |
Example: Observer management patterns
// Singleton pattern - reuse observer across app
class ObserverManager {
constructor() {
this.intersectionObserver = null;
this.resizeObserver = null;
this.observedElements = new Map();
}
// Get or create intersection observer
getIntersectionObserver(options = {}) {
if (!this.intersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const callback = this.observedElements.get(entry.target);
if (callback) {
callback(entry);
}
});
}, options);
}
return this.intersectionObserver;
}
// Observe with custom callback
observeIntersection(element, callback) {
const observer = this.getIntersectionObserver();
this.observedElements.set(element, callback);
observer.observe(element);
}
// Unobserve
unobserveIntersection(element) {
if (this.intersectionObserver) {
this.intersectionObserver.unobserve(element);
this.observedElements.delete(element);
}
}
// Cleanup
destroy() {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.observedElements.clear();
}
}
// Global instance
const observerManager = new ObserverManager();
// Usage across app
observerManager.observeIntersection(element1, (entry) => {
console.log("Element 1 intersecting:", entry.isIntersecting);
});
observerManager.observeIntersection(element2, (entry) => {
console.log("Element 2 intersecting:", entry.isIntersecting);
});
// Debounce helper for resize observer
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Debounced resize handling
const resizeObserver = new ResizeObserver(debounce((entries) => {
entries.forEach(entry => {
// Expensive operation - only runs after 200ms of no resize
console.log("Resize complete:", entry.contentBoxSize);
updateChart(entry.target);
});
}, 200));
// Cleanup pattern for React/Vue/etc
class ComponentWithObserver {
constructor(element) {
this.element = element;
this.observer = new IntersectionObserver(this.handleIntersection.bind(this));
this.observer.observe(this.element);
}
handleIntersection(entries) {
entries.forEach(entry => {
console.log("Intersecting:", entry.isIntersecting);
});
}
// Call on component unmount
destroy() {
this.observer.disconnect();
this.observer = null;
}
}
// Usage in framework
class MyComponent {
mounted() {
this.componentObserver = new ComponentWithObserver(this.$el);
}
unmounted() {
this.componentObserver.destroy();
}
}
Note: Observers are more efficient than event listeners for visibility/size tracking. Use one observer instance for multiple elements when possible. Always disconnect observers on cleanup to prevent memory leaks in SPAs.
5. Lazy Loading Images (IntersectionObserver)
| Lazy Loading Strategy | Description |
|---|---|
| Image Lazy Loading | Load images only when they enter or approach viewport using data-src attribute. |
| Native Lazy Loading | Use <img loading="lazy"> for simple cases (less control). |
| Background Image Loading | Apply background images via CSS class when element becomes visible. |
| Component Lazy Loading | Dynamically import and render components when they enter viewport. |
| Video/Iframe Lazy Loading | Defer loading heavy media until needed to improve initial page load. |
| rootMargin Preloading | Use positive rootMargin (e.g., "100px") to start loading before element visible. |
Example: Lazy load images
// Simple lazy loading
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Load image
img.src = img.dataset.src;
// Optional: load srcset
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
// Remove loading class when loaded
img.addEventListener("load", () => {
img.classList.remove("lazy-loading");
img.classList.add("lazy-loaded");
});
// Handle error
img.addEventListener("error", () => {
img.classList.add("lazy-error");
console.error("Failed to load:", img.dataset.src);
});
// Stop observing this image
observer.unobserve(img);
}
});
}, {
"rootMargin": "50px" // Start loading 50px before entering viewport
});
// Observe all lazy images
document.querySelectorAll("img[data-src]").forEach(img => {
img.classList.add("lazy-loading");
imageObserver.observe(img);
});
// Advanced lazy loading with priority
class LazyLoader {
constructor(options = {}) {
this.options = {
"rootMargin": "100px",
"threshold": 0.01,
...options
};
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
this.options
);
}
handleIntersection(entries, observer) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadElement(entry.target);
observer.unobserve(entry.target);
}
});
}
loadElement(element) {
const type = element.dataset.lazyType || "image";
switch (type) {
case "image":
this.loadImage(element);
break;
case "video":
this.loadVideo(element);
break;
case "iframe":
this.loadIframe(element);
break;
case "background":
this.loadBackground(element);
break;
}
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
return new Promise((resolve, reject) => {
img.addEventListener("load", resolve);
img.addEventListener("error", reject);
if (srcset) img.srcset = srcset;
img.src = src;
img.removeAttribute("data-src");
img.removeAttribute("data-srcset");
}).then(() => {
img.classList.add("loaded");
}).catch(() => {
img.classList.add("error");
});
}
loadVideo(video) {
const sources = video.querySelectorAll("source[data-src]");
sources.forEach(source => {
source.src = source.dataset.src;
source.removeAttribute("data-src");
});
video.load();
video.classList.add("loaded");
}
loadIframe(iframe) {
iframe.src = iframe.dataset.src;
iframe.removeAttribute("data-src");
iframe.classList.add("loaded");
}
loadBackground(element) {
const bgImage = element.dataset.bgSrc;
element.style.backgroundImage = `url(${bgImage})`;
element.removeAttribute("data-bg-src");
element.classList.add("loaded");
}
observe(elements) {
if (elements instanceof NodeList || Array.isArray(elements)) {
elements.forEach(el => this.observer.observe(el));
} else {
this.observer.observe(elements);
}
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const lazyLoader = new LazyLoader({ "rootMargin": "200px" });
// Lazy load images
lazyLoader.observe(document.querySelectorAll("img[data-src]"));
// Lazy load videos
lazyLoader.observe(document.querySelectorAll("video[data-lazy-type='video']"));
// Lazy load iframes (embeds, maps, etc)
lazyLoader.observe(document.querySelectorAll("iframe[data-src]"));
Note: Native lazy loading available with
<img loading="lazy"> but Intersection Observer provides more control. Use rootMargin to preload before entering viewport. Always unobserve after loading to free resources.
6. Infinite Scroll Implementation
| Infinite Scroll Pattern | Description |
|---|---|
| Sentinel Element | Place invisible element at bottom of list; observe it to trigger loading. |
| Loading State | Track loading state to prevent multiple simultaneous requests. |
| hasMore Flag | Boolean flag to stop loading when no more items available. |
| rootMargin Trigger | Use rootMargin to trigger loading before sentinel reaches viewport (better UX). |
| Error Handling | Show error message and retry button if loading fails. |
| End of List Indicator | Display message when all items loaded and stop observing. |
Example: Infinite scroll implementation
// Simple infinite scroll
class InfiniteScroll {
constructor(options) {
this.container = options.container;
this.loadMore = options.loadMore;
this.loading = false;
this.hasMore = true;
// Create sentinel element at bottom
this.sentinel = document.createElement("div");
this.sentinel.className = "infinite-scroll-sentinel";
this.container.appendChild(this.sentinel);
// Observe sentinel
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.loading && this.hasMore) {
this.load();
}
});
}, {
"rootMargin": "100px" // Trigger 100px before sentinel visible
});
this.observer.observe(this.sentinel);
}
async load() {
if (this.loading || !this.hasMore) return;
this.loading = true;
this.showLoader();
try {
const result = await this.loadMore();
// Check if more items available
if (!result || result.items.length === 0) {
this.hasMore = false;
this.showEndMessage();
} else {
this.appendItems(result.items);
}
} catch (error) {
console.error("Load more failed:", error);
this.showError(error);
} finally {
this.loading = false;
this.hideLoader();
}
}
appendItems(items) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const element = this.createItemElement(item);
fragment.appendChild(element);
});
// Insert before sentinel
this.container.insertBefore(fragment, this.sentinel);
}
createItemElement(item) {
const div = document.createElement("div");
div.className = "item";
div.textContent = item.title;
return div;
}
showLoader() {
this.sentinel.classList.add("loading");
this.sentinel.innerHTML = "<div class='loader'>Loading...</div>";
}
hideLoader() {
this.sentinel.classList.remove("loading");
this.sentinel.innerHTML = "";
}
showEndMessage() {
this.sentinel.innerHTML = "<div class='end-message'>No more items</div>";
this.observer.unobserve(this.sentinel);
}
showError(error) {
this.sentinel.innerHTML = `<div class='error'>Error: ${error.message}</div>`;
}
destroy() {
this.observer.disconnect();
this.sentinel.remove();
}
}
// Usage
let currentPage = 1;
const infiniteScroll = new InfiniteScroll({
"container": document.querySelector(".items-container"),
"loadMore": async () => {
const response = await fetch(`/api/items?page=${currentPage}`);
const data = await response.json();
currentPage++;
return data;
}
});
// Advanced infinite scroll with bi-directional loading
class BiDirectionalScroll {
constructor(options) {
this.container = options.container;
this.loadNewer = options.loadNewer;
this.loadOlder = options.loadOlder;
// Create sentinels
this.topSentinel = document.createElement("div");
this.bottomSentinel = document.createElement("div");
this.container.insertBefore(this.topSentinel, this.container.firstChild);
this.container.appendChild(this.bottomSentinel);
// Observe both sentinels
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
if (entry.target === this.topSentinel) {
this.loadNewer && this.loadNewer();
} else if (entry.target === this.bottomSentinel) {
this.loadOlder && this.loadOlder();
}
});
}, { "rootMargin": "200px" });
this.observer.observe(this.topSentinel);
this.observer.observe(this.bottomSentinel);
}
destroy() {
this.observer.disconnect();
}
}
// Cleanup on page navigation
window.addEventListener("beforeunload", () => {
infiniteScroll.destroy();
});
Note: Use sentinel element at bottom of list to trigger loading. Add
rootMargin to start loading before user reaches end. Track loading state to prevent duplicate requests. Always provide visual feedback (loading spinner).
Warning: Infinite scroll can hurt performance with thousands of DOM nodes. Consider virtualization for very long lists. Provide "back to top" button for usability. Be careful with browser history - users expect back button to work.
Observer API Best Practices
- Use Intersection Observer instead of scroll events - much more efficient
- Use Resize Observer instead of window resize events or polling
- Reuse single observer instance for multiple elements of same type
- Call
unobserve()after one-time observations to reduce overhead - Always
disconnect()observers on component unmount to prevent memory leaks - Keep observer callbacks lightweight - defer expensive operations
- Use
rootMarginfor preloading - positive values expand trigger area - Use appropriate
thresholdvalues - 0 for enter/exit, 1.0 for fully visible - Debounce expensive operations in ResizeObserver callbacks
- For lazy loading, unobserve elements after loading to free resources
- For infinite scroll, use sentinel element and track loading state
- Test observer performance with many elements - consider pagination if needed