Intersection and Resize Observer APIs

1. Intersection Observer for Visibility Detection

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 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 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. Resize Observer for Element Size Changes

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 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 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 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.

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 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.

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 rootMargin for preloading - positive values expand trigger area
  • Use appropriate threshold values - 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