JavaScript APIsFeatured

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.

By JavaScript Document Team
intersection-observerweb-apisperformancelazy-loadinganimations

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

  1. 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);
});
  1. Unobserve elements when no longer needed
// Good - clean up after use
if (entry.isIntersecting) {
  loadContent(entry.target);
  observer.unobserve(entry.target);
}
  1. 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;
}
  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.