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 observerconst 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 elementsconst elements = document.querySelectorAll(".observe-me");elements.forEach(el => observer.observe(el));// Unobserve specific elementobserver.unobserve(elements[0]);// Disconnect allobserver.disconnect();// One-time visibility checkconst 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 viewportconst 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 attributedocument.querySelectorAll("[data-animate]").forEach(el => { animateObserver.observe(el);});// Track visibility percentageconst 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 Observer Root Margin and Thresholds
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 visibleconst 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 viewportconst 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 trackingconst 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 containerconst 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.
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 changesconst 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 elementconst box = document.querySelector(".resizable-box");resizeObserver.observe(box);// Observe with specific box modelresizeObserver.observe(box, { "box": "border-box" });// Stop observingresizeObserver.unobserve(box);resizeObserver.disconnect();
Example: Responsive component behavior
// Container queries with ResizeObserverclass 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(); }}// Usageconst 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 containerconst 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 Patterns and Performance Best Practices
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 appclass 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 instanceconst observerManager = new ObserverManager();// Usage across appobserverManager.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 observerfunction debounce(func, wait) { let timeout; return function executedFunction(...args) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); };}// Debounced resize handlingconst 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/etcclass 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 frameworkclass 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 Implementation with Observers
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.
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 Scrolling with Intersection Observer
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.
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