← Back to DevBytes

IntersectionObserver v2: Complete Guide

What is IntersectionObserver v2?

IntersectionObserver v2 is an evolution of the original Intersection Observer API that provides deeper insight into the actual visibility of elements. While the first version (v1) reports only geometric intersection—whether an element's bounding box overlaps the viewport or a specified root—v2 adds the ability to track visual occlusion caused by factors like opacity, CSS transforms, filters, and overlapping DOM elements. It introduces a new isVisible flag and optional throttling via a delay parameter, enabling developers to build more reliable, user-aware experiences.

This guide covers everything you need to know about IntersectionObserver v2: its motivation, API surface, practical code examples, and best practices for production use.

Why IntersectionObserver v2 Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditional intersection detection answers: "Is the element inside the viewport?" But that's only half the picture. An element might intersect the viewport yet remain completely invisible to the user because:

V2 solves these blind spots. When you enable visibility tracking, the observer takes into account the element's effective visual state across the entire rendering pipeline—including compositor-level effects like opacity and transforms. This is critical for analytics, ad viewability, lazy-loading of critical UI components, and any scenario where you need to know that the user can actually see the element.

How to Use IntersectionObserver v2

Enabling Visibility Tracking

To activate v2 features, you pass a new option trackVisibility (boolean) and an optional delay (milliseconds) to the observer's constructor. When trackVisibility is true, each IntersectionObserverEntry gains a boolean isVisible property, and the callback fires whenever that visibility state changes—even if the intersection ratio stays the same.


const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      console.log('isVisible:', entry.isVisible);
      console.log('intersectionRatio:', entry.intersectionRatio);
    });
  },
  {
    trackVisibility: true,
    delay: 100        // throttle updates to at most one per 100ms
  }
);

observer.observe(document.querySelector('.target'));

The delay option is a performance-conscious throttle. It limits how frequently the observer reports visibility changes, helping you avoid unnecessary work during rapid state transitions (like scrolling through a long list). A delay of 0 disables throttling, while typical values range from 50ms to 200ms.

Understanding the isVisible Property

The isVisible flag is computed based on a combination of factors:

Importantly, isVisible is independent of the geometric intersection ratio. A fully visible element (intersectionRatio = 1) can still have isVisible: false if it's hidden behind a transparent overlay with pointer-events: none? Actually, the spec accounts for visual occlusion, so an opaque overlay will mark it as not visible. The exact algorithm is defined in the spec, but for practical purposes, you can rely on it as a trustworthy signal of true user visibility.

Practical Example: Guaranteeing Lazy Image Loads Only When Truly Visible

A common use case is lazy-loading images. With v1 you might load an image as soon as it intersects the viewport. But what if a sticky banner covers the image's area? The user never sees it, yet the image loads. V2 lets you delay loading until the image becomes actually visible.


const lazyImages = document.querySelectorAll('img[data-src]');

const visibilityObserver = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach(entry => {
      // Load only when both intersecting AND truly visible
      if (entry.isVisible && entry.intersectionRatio > 0) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    });
  },
  {
    trackVisibility: true,
    delay: 100,
    threshold: 0.01  // any non-zero intersection
  }
);

lazyImages.forEach(img => visibilityObserver.observe(img));

Notice we combine isVisible with a minimal geometric threshold. This ensures the element has entered the viewport area and is not hidden by visual effects or overlays. For a more aggressive strategy, you could even ignore intersectionRatio entirely and rely solely on isVisible, but that might trigger loads when the element is out of view yet somehow visible (rare, but possible via transforms). Combining both is the safest.

Tracking Visibility-Only Changes

Sometimes you only care about visibility changes, not geometric intersection. For example, you want to pause an animation when the element becomes obscured by an ad overlay, regardless of scrolling. V2 lets you set a high root margin to effectively ignore viewport intersection and only react to visibility.


const pauseController = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      const player = entry.target.querySelector('video');
      if (player) {
        entry.isVisible ? player.play() : player.pause();
      }
    });
  },
  {
    trackVisibility: true,
    delay: 0,
    // Make root margins huge so geometric intersection is always true
    rootMargin: '1000px 1000px 1000px 1000px'
  }
);

document.querySelectorAll('.video-container').forEach(el => pauseController.observe(el));

By inflating the root margins, we ensure the observer always considers the element "intersecting", thus the callback fires only when isVisible toggles—effectively a pure visibility observer.

Best Practices

Feature Detection and Fallback

To safely use v2 in production, wrap your observer creation in a capability check. The snippet below attempts v2 and falls back to v1 gracefully.


function createVisibilityObserver(callback, options = {}) {
  // Default v2 options
  const v2Options = {
    trackVisibility: true,
    delay: options.delay ?? 100,
    ...options
  };

  // Try to create a v2 observer by checking if 'trackVisibility' is accepted
  try {
    const testObserver = new IntersectionObserver(() => {}, v2Options);
    // If no error and the option is reflected (Chrome), use v2
    if (testObserver.trackVisibility !== undefined) {
      testObserver.disconnect(); // clean up test
      return new IntersectionObserver(callback, v2Options);
    }
  } catch (e) {
    // Fallback to v1
  }

  // Fallback: use standard IntersectionObserver with v1 options only
  const v1Options = { ...options };
  delete v1Options.trackVisibility;
  delete v1Options.delay;
  return new IntersectionObserver(callback, v1Options);
}

// Usage
const observer = createVisibilityObserver((entries) => {
  entries.forEach(entry => {
    // entry.isVisible might be undefined in fallback, handle gracefully
    const visible = entry.isVisible !== undefined ? entry.isVisible : entry.isIntersecting;
    if (visible) {
      // do something
    }
  });
}, { threshold: 0.1, delay: 50 });
observer.observe(document.getElementById('ad-unit'));

Conclusion

IntersectionObserver v2 fills a critical gap left by its predecessor—answering not just whether an element sits inside the viewport, but whether the user can actually see it. By leveraging trackVisibility and delay, you can build smarter lazy-loading, precise viewability analytics, and adaptive UI behaviors that respond to real-world visual conditions. While browser support is still evolving, feature detection and progressive enhancement make it safe to adopt today. Start with the most impactful elements, combine visibility and intersection checks, and enjoy a new level of control over what your users truly experience.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles