DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

Intersection Observer API: Lazy Loading, Infinite Scroll, Scroll Animations, and TOC Active Links

The Intersection Observer API lets you detect when elements enter or leave the viewport — without polling, without scroll event listeners, and without expensive getBoundingClientRect() calls. It's the modern solution for lazy loading, infinite scroll, animation triggers, and ad viewability measurement. Firefox has full support.

Why Intersection Observer

The old approach to detecting element visibility:

// BAD: forces synchronous layout on every scroll event
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect();
  if (rect.top < window.innerHeight) {
    loadImage();
  }
});
Enter fullscreen mode Exit fullscreen mode

Problems:

  • getBoundingClientRect() forces a synchronous layout (expensive)
  • Scroll events fire rapidly (60+ times/second on smooth scroll)
  • Main thread blocking causes jank

Intersection Observer:

  • Runs asynchronously, not on the main thread
  • Callbacks only fire when intersection changes (not on every scroll)
  • Batches observations
  • ~10-100x less CPU usage

Basic Intersection Observer

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // Element entered viewport
      loadContent(entry.target);
      observer.unobserve(entry.target); // Stop watching after first load
    }
  });
});

// Start observing elements
document.querySelectorAll('.lazy-image').forEach(img => {
  observer.observe(img);
});
Enter fullscreen mode Exit fullscreen mode

Options: threshold and rootMargin

const observer = new IntersectionObserver(callback, {
  // threshold: percentage visible that triggers callback
  threshold: 0,        // trigger when any pixel is visible (default)
  threshold: 0.5,      // trigger when 50% is visible
  threshold: 1.0,      // trigger only when fully visible
  threshold: [0, 0.25, 0.5, 0.75, 1.0], // trigger at each percentage

  // rootMargin: expand/contract the observation area
  rootMargin: '0px',           // default (exactly viewport)
  rootMargin: '200px',         // start loading 200px before entering viewport
  rootMargin: '-20px',         // only trigger when element is 20px inside viewport
  rootMargin: '0px 0px -100px 0px', // top right bottom left

  // root: default is the viewport
  root: document.getElementById('scroll-container'), // or any scrollable element
});
Enter fullscreen mode Exit fullscreen mode

Lazy Loading Images

<img data-src="/photo.jpg" class="lazy" alt="Photo" loading="lazy">
Enter fullscreen mode Exit fullscreen mode
// IntersectionObserver approach (works everywhere, more control)
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      imageObserver.unobserve(img);
    }
  });
}, { rootMargin: '300px' }); // Start loading 300px before viewport

document.querySelectorAll('img.lazy').forEach(img => imageObserver.observe(img));
Enter fullscreen mode Exit fullscreen mode

Alternative: the loading="lazy" HTML attribute is simpler for images and <iframe> elements. Firefox supports it natively. The IntersectionObserver approach gives you more control (custom loading indicator, threshold, etc.).

Infinite Scroll

// Add a sentinel element at the bottom of the list
const sentinel = document.getElementById('list-sentinel');

const sentinelObserver = new IntersectionObserver(async (entries) => {
  if (entries[0].isIntersecting) {
    await loadMoreItems();
  }
}, { rootMargin: '100px' }); // Load 100px before reaching the bottom

sentinelObserver.observe(sentinel);

async function loadMoreItems() {
  const newItems = await fetchNextPage();
  renderItems(newItems);
  // sentinel is still being observed — will trigger again when it comes back into view
}
Enter fullscreen mode Exit fullscreen mode

Scroll-Triggered Animations

const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate-in');
    }
    // Optionally remove class to re-animate on scroll up:
    // else { entry.target.classList.remove('animate-in'); }
  });
}, { threshold: 0.15 }); // Trigger when 15% visible

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  animationObserver.observe(el);
});
Enter fullscreen mode Exit fullscreen mode
.animate-on-scroll {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.5s, transform 0.5s;
}

.animate-on-scroll.animate-in {
  opacity: 1;
  transform: translateY(0);
}
Enter fullscreen mode Exit fullscreen mode

Reading Progress Indicator

const article = document.getElementById('article');
const progressBar = document.getElementById('progress-bar');

const progressObserver = new IntersectionObserver((entries) => {
  const articleRect = article.getBoundingClientRect();
  const progress = Math.min(
    1,
    Math.max(0, (window.innerHeight - articleRect.top) / articleRect.height)
  );
  progressBar.style.width = (progress * 100) + '%';
}, { threshold: Array.from({ length: 101 }, (_, i) => i / 100) }); // 100 thresholds

progressObserver.observe(article);
Enter fullscreen mode Exit fullscreen mode

Table of Contents Active Link

const headings = document.querySelectorAll('h2, h3');
const tocLinks = document.querySelectorAll('.toc a');

const headingObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const id = entry.target.id;
    const tocLink = document.querySelector(`.toc a[href="#${id}"]`);

    if (entry.isIntersecting) {
      tocLinks.forEach(l => l.classList.remove('active'));
      tocLink?.classList.add('active');
    }
  });
}, { rootMargin: '-100px 0px -66% 0px' }); // Focus on top third of viewport

headings.forEach(h => headingObserver.observe(h));
Enter fullscreen mode Exit fullscreen mode

IntersectionObserverEntry Properties

In the callback, each entry has:

entry.isIntersecting         // boolean: visible now?
entry.intersectionRatio      // 0-1: how much is visible
entry.boundingClientRect     // element's position
entry.intersectionRect       // visible portion's position
entry.rootBounds             // viewport/root bounds
entry.target                 // the DOM element
entry.time                   // DOMHighResTimeStamp when the change was detected
Enter fullscreen mode Exit fullscreen mode

Cleanup

Always disconnect when no longer needed:

// Stop observing a specific element
observer.unobserve(element);

// Disconnect all observations (clean up when component unmounts)
observer.disconnect();
Enter fullscreen mode Exit fullscreen mode

In React:

useEffect(() => {
  const observer = new IntersectionObserver(callback);
  observer.observe(elementRef.current);
  return () => observer.disconnect(); // cleanup
}, []);
Enter fullscreen mode Exit fullscreen mode

Firefox DevTools Visualization

There's no dedicated Intersection Observer panel in DevTools, but you can debug it:

  1. Add console.log(entry) in the callback
  2. Open DevTools Console
  3. Scroll the page — you'll see entries logged with full IntersectionObserverEntry data

Check entry.intersectionRatio and entry.rootBounds to verify your thresholds are triggering correctly.

Top comments (0)