Trust is good, observation is better: Intersection Observer v2

Intersection Observer v2 adds the capability to not only observe intersections per se, but to also detect if the intersecting element was visible at the time of intersection.

Intersection Observer v1 is one of those APIs that's probably universally loved, and, now that Safari supports it as well, it's also finally universally usable in all major browsers. For a quick refresher of the API, I recommend watching Surma's Supercharged Microtip on Intersection Observer v1 that is embedded below. You can also read Surma's in-depth article. People have used Intersection Observer v1 for a wide range of use cases like lazy loading of images and videos, being notified when elements reach position: sticky, firing analytics events, and many more.

For the full details, check out the Intersection Observer docs on MDN, but as a short reminder, this is what the Intersection Observer v1 API looks like in the most basic case:

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'));

What's challenging with Intersection Observer v1?

To be clear, Intersection Observer v1 is great, but it's not perfect. There are some corner cases where the API falls short. Let's have a closer look! The Intersection Observer v1 API can tell you when an element is scrolled into the window's viewport, but it doesn't tell you whether the element is covered by any other page content (that is, when the element is occluded) or whether the element's visual display has been modified by visual effects like transform, opacity, filter, etc., which effectively can make it invisible.

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

Why is actual visibility such a big deal?

The Internet is, unfortunately, a place that attracts bad actors with worse intentions. For example, a shady publisher that serves pay-per-click ads on a content site might be incentivized to trick people into clicking their ads to increase the publisher's ad payout (at least for a short period, until the ad network catches them). Typically, such ads are served in iframes. Now if the publisher wanted to get users to click such ads, they could make the ad iframes completely transparent by applying a CSS rule iframe { opacity: 0; } and overlaying the iframes on top of something attractive, like a cute cat video that users would actually want to click. This is called clickjacking. You can see such a clickjacking attack in action in the upper section of this demo (try "watching" the cat video and activate "trick mode"). You will notice that the ad in the iframe "thinks" it received legitimate clicks, even if it was completely transparent when you (pretend-involuntarily) clicked it.

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

How does Intersection Observer v2 fix this?

Intersection Observer v2 introduces the concept of tracking the actual "visibility" of a target element as a human being would define it. By setting an option in the IntersectionObserver constructor, intersecting IntersectionObserverEntry instances will then contain a new boolean field named isVisible. A true value for isVisible is a strong guarantee from the underlying implementation that the target element is completely unoccluded by other content and has no visual effects applied that would alter or distort its display on screen. In contrast, a false value means that the implementation cannot make that guarantee.

An important detail of the spec is that the implementation is permitted to report false negatives (that is, setting isVisible to false even when the target element is completely visible and unmodified). For performance or other reasons, browsers limit themselves to working with bounding boxes and rectilinear geometry; they don't try to achieve pixel-perfect results for modifications like border-radius.

That said, false positives are not permitted under any circumstances (that is, setting isVisible to true when the target element is not completely visible and unmodified).

What does the new code look like in practice?

The IntersectionObserver constructor now takes two additional configuration properties: delay and trackVisibility. The delay is a number indicating the minimum delay in milliseconds between notifications from the observer for a given target. The trackVisibility is a boolean indicating whether the observer will track changes in a target's visibility.

It's important to note here that when trackVisibility is true, delay is required to be at least 100 (that is, no more than one notification every 100ms). As noted before, visibility is expensive to calculate, and this requirement is a precaution against performance degradation and battery consumption. The responsible developer will use the largest tolerable value for delay.

According to the current spec, visibility is calculated as follows:

  • If the observer's trackVisibility attribute is false, then the target is considered visible. This corresponds to the current v1 behavior.

  • If the target has an effective transformation matrix other than a 2D translation or proportional 2D upscaling, then the target is considered invisible.

  • If the target, or any element in its containing block chain, has an effective opacity other than 1.0, then the target is considered invisible.

  • If the target, or any element in its containing block chain, has any filters applied, then the target is considered invisible.

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

This means current implementations are pretty conservative with guaranteeing visibility. For example, applying an almost unnoticeable grayscale filter like filter: grayscale(0.01%) or setting an almost invisible transparency with opacity: 0.99 would all render the element invisible.

Below is a short code sample that illustrates the new API features. You can see its click tracking logic in action in the second section of the demo (but now, try "watching" the puppy video). Be sure to activate "trick mode" again to immediately convert yourself into a shady publisher and see how Intersection Observer v2 prevents non-legitimate ad clicks from being tracked. This time, Intersection Observer v2 has our back! 🎉

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 Intersection Observer v2, falling back 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'));

Acknowledgements

Thanks to Simeon Vincent, Yoav Weiss, and Mathias Bynens for reviewing this article, as well as Stefan Zager likewise for reviewing and for implementing the feature in Chrome. Hero image by Sergey Semin on Unsplash.