Trust is good, observation is best: Intersection Observer v2

Browser Support

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

Source

Intersection Observer is one of those APIs that's probably universally loved, and it's usable in all major browsers. Developers have used this API for a wide range of use cases, including lazy loading images and videos, notifications when elements reach position: sticky, firing analytics events, and many more.

In its most basic form, this is what the Intersection Observer v1 API looks like:

const onIntersection = (entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      console.log(entry);
    }
  }
};

const observer = new IntersectionObserver(onIntersection);
observer.observe(document.querySelector('#some-target'));

Visibility challenges in Intersection Observer v1

With the Intersection Observer v1 API, you can learn when an element is scrolled into the window's viewport. However, you can't determine if that element is covered by other page content, known as occlusion, or if the element appear modified by CSS, such as transform, opacity, or filter, which may make the element it invisible.

For an element in the top-level document, this information can be determined by analyzing the DOM with JavaScript, for example with DocumentOrShadowRoot.elementFromPoint(). In contrast, the same information cannot be obtained if the element in question is located in a third-party iframe.

Why is visibility important?

Unfortunately, the internet has bad actors. For example, a dishonest publisher might use pay-per-click ads on a website. They might trick users into clicking these ads to earn more money, at least until the ad network discovers their scheme. Typically, such ads are served in iframes.

To trick users, the publisher could make the ad iframes completely transparent with CSS: iframe { opacity: 0; }. Then, they could place these transparent iframes over appealing content, like a cute cat video, that users want to click. This is called clickjacking.

You can see such a clickjacking attack in action in the upper section of our demo. Try "watching" the cat video and activate trick mode. The ad in the iframe registers clicks as legitimate, even if you (unintentionally) clicked it while the iframe was transparent.

Tricking a user into clicking an ad by styling it transparent and overlaying it on top of something attractive.

Improvements in Intersection Observer v2

Intersection Observer v2 can track an element's "visibility" as a human would define it. If you set an option in the IntersectionObserver constructor, the resulting IntersectionObserverEntry instances include a new boolean field called isVisible. When isVisible is true, the browser makes sure the element is completely uncovered by other content and has no visual effects that hide or change its display. If isVisible is false, the browser cannot make that guarantee.

The spec allows false negatives: isVisible can be false even when the element is truly visible and unchanged. For performance, browsers use simpler calculations, such as bounding boxes and rectangular shapes, and don't check every pixel for complex details like border-radius.

However, false positives are not permitted under any circumstances. This means isVisible won't be true if the element is not completely visible and unmodified.

Apply these changes

The IntersectionObserver constructor now takes two additional configuration properties:

  • delay is a number that indicates the minimum delay in milliseconds between notifications from the observer, for a given target.
  • trackVisibility is a boolean to indicate if the observer will track changes in a target's visibility.

When trackVisibility is true, delay must be set to 100 or a higher value (that is, no more than one notification every 100ms). As visibility is expensive to calculate, this is a precaution against performance degradation and battery consumption. Responsible developers should use the largest tolerable value for delay.

The spec, calculates visibility. Like with version 1, when observer's trackVisibility attribute is false, the target is considered visible

In version 2, the target is considered invisible if:

  • It has an effective transformation matrix, other than a 2D translation or proportional 2D upscaling.

  • The target, or any element in its containing block chain, has an effective opacity smaller than 1.0.

  • The target, or any element in its containing block chain, has any filters applied.

  • If the implementation cannot guarantee that the target is completely unoccluded by other page content.

This means current implementations are pretty conservative in guaranteeing visibility. For example, applying a nearly unnoticeable grayscale filter (filter: grayscale(0.01%)) or setting the smallest transparency (opacity: 0.99) would render the element invisible.

Here is a code sample that illustrates the new API features. You can see its click tracking logic in action in the second section of the demo Try "watching" the puppy video. Activate trick mode to convert yourself into a bad actor and see how Intersection Observer v2 prevents non-legitimate ad clicks from being tracked. Intersection Observer v2 protects us.

Intersection Observer v2 preventing an unintended click on an ad.

<!DOCTYPE html>
<!-- This is the ad running in the iframe -->
<button id="callToActionButton">Buy now!</button>
// This is code running in the iframe.

// The iframe must be visible for at least 800ms prior to an input event
// for the input event to be considered valid.
const minimumVisibleDuration = 800;

// Keep track of when the button transitioned to a visible state.
let visibleSince = 0;

const button = document.querySelector('#callToActionButton');
button.addEventListener('click', (event) => {
  if ((visibleSince > 0) &&
      (performance.now() - visibleSince >= minimumVisibleDuration)) {
    trackAdClick();
  } else {
    rejectAdClick();
  }
});

const observer = new IntersectionObserver((changes) => {
  for (const change of changes) {
    // ⚠️ Feature detection
    if (typeof change.isVisible === 'undefined') {
      // The browser doesn't support v2, fallback to v1 behavior.
      change.isVisible = true;
    }
    if (change.isIntersecting && change.isVisible) {
      visibleSince = change.time;
    } else {
      visibleSince = 0;
    }
  }
}, {
  threshold: [1.0],
  // 🆕 Track the actual visibility of the element
  trackVisibility: true,
  // 🆕 Set a minimum delay between notifications
  delay: 100
}));

// Require that the entire iframe be visible.
observer.observe(document.querySelector('#ad'));

Additional resources

Acknowledgements

Thanks to Simeon Vincent, Yoav Weiss, and Mathias Bynens for reviewing, as well as Stefan Zager for reviewing and implementing the feature in Chrome.