Intersection Observer API

Introduction to Intersection Observer

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with the document's viewport. This allows you to efficiently detect when elements enter or exit the viewport, enabling performance-optimized implementations of:

  • Lazy loading of images and other content
  • Infinite scrolling of content
  • Scroll-based animations and effects
  • Ad visibility tracking and analytics
  • Sticky headers and navigation elements

Why Use Intersection Observer?

Before Intersection Observer, detecting visibility required event handlers and expensive DOM queries:


// The old way (avoid this)
window.addEventListener('scroll', function() {
  // This runs on every scroll event (performance nightmare)
  const element = document.querySelector('.my-element');
  const position = element.getBoundingClientRect();
  
  // Check if element is visible
  if (position.top < window.innerHeight && position.bottom >= 0) {
    console.log('Element is visible!');
  }
});
                        

This approach has several problems:

  • Scroll event handlers run on the main thread
  • They fire many times during scrolling (potential performance bottleneck)
  • getBoundingClientRect() forces layout recalculation
Key Insight: Intersection Observer solves these problems by providing a callback that only runs when an element's visibility changes according to your specified thresholds, and it runs off the main thread for better performance.

Basic Usage

Creating an Observer

The basic pattern for using Intersection Observer involves creating an observer with a callback function and options, then observing one or more target elements:


// Step 1: Create a callback function
const callback = (entries, observer) => {
  entries.forEach(entry => {
    // entry.isIntersecting is true when element is visible
    if (entry.isIntersecting) {
      console.log('Element is visible in the viewport!');
      
      // Optional: Stop observing the element
      // observer.unobserve(entry.target);
    } else {
      console.log('Element is not visible in the viewport!');
    }
  });
};

// Step 2: Create observer with options
const options = {
  root: null,         // Use the viewport as the root
  rootMargin: '0px',  // No margin around the root
  threshold: 0.5      // Trigger when 50% of the element is visible
};

const observer = new IntersectionObserver(callback, options);

// Step 3: Start observing an element
const target = document.querySelector('.my-element');
observer.observe(target);

// Step 4 (Optional): Stop observing when no longer needed
// observer.unobserve(target);

// Step 5 (Optional): Disconnect the observer completely when done
// observer.disconnect();
                        

Understanding the Options

Option Description Example Values
root The element that is used as the viewport for checking visibility. Must be an ancestor of the target. null (default, uses viewport)
document.querySelector('.container')
rootMargin Margin around the root, effectively growing or shrinking the area used for intersection. Uses CSS margin syntax. '0px' (default)
'10px 20px 30px 40px' (top, right, bottom, left)
'-10px' (negative values shrink the root area)
threshold Percentage of the target's visibility the observer's callback should be executed. Can be a single number or an array of numbers. 0 (as soon as one pixel is visible)
1.0 (when the entire element is visible)
0.5 (when 50% is visible)
[0, 0.25, 0.5, 0.75, 1] (multiple thresholds)

The Intersection Entry Object

The callback receives an array of IntersectionObserverEntry objects with these key properties:


const callback = (entries, observer) => {
  entries.forEach(entry => {
    console.log('Target element:', entry.target);
    console.log('Is intersecting:', entry.isIntersecting);
    console.log('Intersection ratio:', entry.intersectionRatio);
    console.log('Bounding client rect:', entry.boundingClientRect);
    console.log('Intersection rect:', entry.intersectionRect);
    console.log('Root bounds:', entry.rootBounds);
    console.log('Time since observation began:', entry.time);
  });
};
                        

Practical Examples

Lazy Loading Images

One of the most common uses for Intersection Observer is lazy loading images to improve page load performance:


// HTML structure:
// <img class="lazy-image" data-src="actual-image-url.jpg" src="placeholder.jpg" alt="Description">

document.addEventListener('DOMContentLoaded', function() {
  const lazyImages = document.querySelectorAll('.lazy-image');
  
  // Create an intersection observer
  const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      // If the image is in viewport
      if (entry.isIntersecting) {
        const img = entry.target;
        
        // Replace the placeholder with the actual image
        img.src = img.dataset.src;
        
        // Optional: Add a class for fade-in effect
        img.classList.add('loaded');
        
        // Stop observing the image
        observer.unobserve(img);
      }
    });
  }, {
    rootMargin: '50px 0px',  // Start loading images before they appear in viewport
    threshold: 0.01          // Trigger when just 1% of the image is visible
  });
  
  // Observe all lazy images
  lazyImages.forEach(image => {
    imageObserver.observe(image);
  });
});

// CSS for fade-in effect
/*
.lazy-image {
  opacity: 0;
  transition: opacity 0.3s ease-in;
}

.lazy-image.loaded {
  opacity: 1;
}
*/
                        

Infinite Scrolling

Implement infinite scrolling by loading more content when the user reaches the bottom of the page:


// HTML structure:
// <div id="content">...initial content...</div>
// <div id="loading-indicator">Loading more items...</div>

let page = 1;
let isLoading = false;

document.addEventListener('DOMContentLoaded', function() {
  const loadingIndicator = document.getElementById('loading-indicator');
  
  // Create an intersection observer for the loading indicator
  const loadMoreObserver = new IntersectionObserver((entries, observer) => {
    // If loading indicator is visible and we're not already loading
    if (entries[0].isIntersecting && !isLoading) {
      loadMoreContent();
    }
  }, {
    rootMargin: '100px 0px'  // Start loading before user reaches the very bottom
  });
  
  // Start observing the loading indicator
  loadMoreObserver.observe(loadingIndicator);
  
  // Function to load more content
  async function loadMoreContent() {
    isLoading = true;
    
    try {
      // Show loading state
      loadingIndicator.textContent = 'Loading more items...';
      
      // Fetch next page of content
      const response = await fetch(`/api/content?page=${++page}`);
      
      if (!response.ok) throw new Error('Failed to load more content');
      
      const data = await response.json();
      
      // If no more data, stop observing
      if (data.items.length === 0) {
        loadingIndicator.textContent = 'No more content to load';
        loadMoreObserver.unobserve(loadingIndicator);
        return;
      }
      
      // Append new content to the page
      const contentContainer = document.getElementById('content');
      
      data.items.forEach(item => {
        const itemElement = document.createElement('div');
        itemElement.classList.add('content-item');
        itemElement.innerHTML = `
          

${item.title}

${item.description}

`; contentContainer.appendChild(itemElement); }); // Reset loading state loadingIndicator.textContent = 'Scroll for more'; } catch (error) { console.error('Error loading more content:', error); loadingIndicator.textContent = 'Error loading content. Try again.'; } finally { isLoading = false; } } });

Scroll-Based Animations

Trigger animations when elements come into view:


// HTML structure:
// <div class="animate-on-scroll" data-animation="fade-in">...</div>
// <div class="animate-on-scroll" data-animation="slide-up">...</div>

document.addEventListener('DOMContentLoaded', function() {
  const animatedElements = document.querySelectorAll('.animate-on-scroll');
  
  // Create animation observer
  const animationObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // Get the animation type from data attribute
        const animationType = entry.target.dataset.animation;
        
        // Add the animation class
        entry.target.classList.add(animationType);
        
        // Stop observing after animation is triggered
        observer.unobserve(entry.target);
      }
    });
  }, {
    threshold: 0.2  // Trigger when 20% of the element is visible
  });
  
  // Observe all animated elements
  animatedElements.forEach(element => {
    animationObserver.observe(element);
  });
});

// CSS for animations
/*
.animate-on-scroll {
  opacity: 0;
  transition: all 0.5s ease-out;
}

.fade-in {
  opacity: 1;
}

.slide-up {
  opacity: 1;
  transform: translateY(0);
}

.animate-on-scroll[data-animation="slide-up"] {
  transform: translateY(20px);
}
*/
                        

Advanced Techniques

Using a Custom Root Element

You can observe intersections within a scrollable container instead of the viewport:


// HTML structure:
// <div id="scrollable-container">
//   <div class="item">Item 1</div>
//   <div class="item">Item 2</div>
//   ...
// </div>

const container = document.getElementById('scrollable-container');
const items = container.querySelectorAll('.item');

// Create observer with the container as root
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    } else {
      entry.target.classList.remove('visible');
    }
  });
}, {
  root: container,     // Use the container as the viewport
  threshold: 0.5       // 50% visibility threshold
});

// Observe all items
items.forEach(item => {
  observer.observe(item);
});
                        

Multiple Thresholds for Progressive Effects

Use multiple thresholds to create progressive effects based on how much of an element is visible:


const progressiveObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    // Get the target element
    const element = entry.target;
    
    // Calculate opacity based on intersection ratio
    const opacity = Math.round(entry.intersectionRatio * 10) / 10;
    
    // Apply styles based on visibility
    element.style.opacity = opacity;
    
    // Apply different classes based on thresholds
    if (entry.intersectionRatio >= 0.75) {
      element.classList.add('fully-visible');
      element.classList.remove('partially-visible');
    } else if (entry.intersectionRatio >= 0.25) {
      element.classList.add('partially-visible');
      element.classList.remove('fully-visible');
    } else {
      element.classList.remove('partially-visible', 'fully-visible');
    }
    
    console.log(`Element visibility: ${entry.intersectionRatio * 100}%`);
  });
}, {
  threshold: [0, 0.25, 0.5, 0.75, 1]  // Multiple thresholds
});

// Observe elements
document.querySelectorAll('.progressive-element').forEach(element => {
  progressiveObserver.observe(element);
});
                        

Sticky Navigation with Intersection Observer

Create a sticky header that appears when scrolling past a certain point:


// HTML structure:
// <div id="header-sentinel"></div>
// <header id="sticky-header">...</header>

document.addEventListener('DOMContentLoaded', function() {
  const sentinel = document.getElementById('header-sentinel');
  const header = document.getElementById('sticky-header');
  
  // Create observer for the sentinel element
  const headerObserver = new IntersectionObserver((entries) => {
    // When sentinel leaves viewport (scrolled past it)
    if (!entries[0].isIntersecting) {
      header.classList.add('sticky');
    } else {
      header.classList.remove('sticky');
    }
  }, {
    threshold: 0,
    rootMargin: '0px'
  });
  
  // Start observing the sentinel
  headerObserver.observe(sentinel);
});

// CSS for sticky header
/*
#header-sentinel {
  position: absolute;
  top: 0;
  left: 0;
  height: 1px;
  width: 100%;
  z-index: -1;
}

#sticky-header {
  position: relative;
  background: white;
  padding: 15px;
  z-index: 100;
  transition: all 0.3s ease;
}

#sticky-header.sticky {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
*/
                        

Performance Considerations

Best Practices

  • Reuse observers: Create a single observer for multiple elements with the same behavior
  • Unobserve when done: Call unobserve() once you no longer need to track an element
  • Use appropriate thresholds: Only use the thresholds you actually need
  • Consider rootMargin: Use rootMargin to start loading before elements are visible
  • Avoid heavy operations: Keep your callback functions light and efficient

Potential Pitfalls

Common Issues to Avoid:
  • Too many observers: Creating a new observer for each element can impact performance
  • Not unobserving: Forgetting to unobserve elements that no longer need tracking
  • Complex callbacks: Performing heavy DOM operations in the callback function
  • Too many thresholds: Using many thresholds when fewer would suffice

Measuring Performance


// Performance measurement example
console.time('Observer Setup');

const elements = document.querySelectorAll('.tracked-element');
const observer = new IntersectionObserver((entries) => {
  // Measure callback execution time
  console.time('Observer Callback');
  
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // Your logic here
    }
  });
  
  console.timeEnd('Observer Callback');
}, { threshold: 0.1 });

elements.forEach(element => observer.observe(element));

console.timeEnd('Observer Setup');
                        

Browser Support and Polyfills

Browser Support

Intersection Observer has good support in modern browsers:

  • Chrome: 51+
  • Firefox: 55+
  • Safari: 12.1+
  • Edge: 15+

Using a Polyfill

For older browsers, you can use the Intersection Observer polyfill:


// Check if Intersection Observer is supported
if (!('IntersectionObserver' in window)) {
  // Load polyfill
  const script = document.createElement('script');
  script.src = 'https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver';
  document.head.appendChild(script);
  
  script.onload = () => {
    // Initialize your observers once the polyfill is loaded
    initializeObservers();
  };
} else {
  // Browser supports Intersection Observer natively
  initializeObservers();
}

function initializeObservers() {
  // Your Intersection Observer code here
}
                        

Feature Detection

Always use feature detection to provide fallbacks for browsers without support:


// Lazy loading with fallback
function initializeLazyLoading() {
  const lazyImages = document.querySelectorAll('.lazy-image');
  
  if ('IntersectionObserver' in window) {
    // Use Intersection Observer
    const imageObserver = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadImage(entry.target);
          observer.unobserve(entry.target);
        }
      });
    });
    
    lazyImages.forEach(image => imageObserver.observe(image));
  } else {
    // Fallback for browsers without support
    // Load all images immediately
    lazyImages.forEach(image => loadImage(image));
  }
}

function loadImage(img) {
  if (img.dataset.src) {
    img.src = img.dataset.src;
    img.classList.add('loaded');
  }
}
                        

Resources and Further Reading

Documentation

Tools and Libraries

Tutorials and Articles

Final Thought: The Intersection Observer API is a powerful tool that solves many common web development challenges in a performance-friendly way. By offloading visibility detection to the browser, you can create smoother, more efficient web experiences that respond to user scrolling without the performance penalties of older techniques.