Quick introduction to the Intersection Observer API

These last years browsers vendors have paid more attention to the riddles developers were trying to solve to implement native and more performant solutions, and this one was a high on the list.

The new Intersection Observer interface is here as a response of developers trying to figure out the best way to detect when an element enters the viewport. Doing this is useful in a lot of cases like infinite scrolling, lazy loading images or animating content.

Create a new observer

First thing you need to do is to create an observer passing a callback function that will be executed everytime an element or more changes its status and an options object to configure the observer's behavior.

const observer = new IntersectionObserver(onChange, {
  threshold: [0.25]
});

function onChange(changes) {
  // for each element that has become visible
  changes.forEach((entry) => {
    // change in one of the targets observed
    console.log(entry);
  });
}

The callback will receive a collection of all changes detected. Important to say this doesn't mean that all of them correspond to elements that have become visible, but elements whose intersectionRatio has changed.

Add targets

We haven't told the observer what elements to look at yet.

For this we use the observe method.

// add one element
const image = document.querySelector('.lazy--image')
observer.observe(image)

// multiple elements
const hiddenElements = document.querySelectorAll('.hidden')
[ ...hiddenElements ].forEach(el => observer.observe(el))

Going back to our onChange callback, we need to understand which of these entries mean and how to use them to react to ratio changes.

Inspecting intersection entries

Each entry you receive the observer callback will contain a target property containing a reference of the element, an intersectionRatio which goes from 0 to 1.0 which is a coeficient indicating the visible portion of the target and an easier to read isIntersecting property which will be true only when the visible ratio is bigger than 0.

Better checking this in a code snippet.

// create observer
const observer = new IntersectionObserver(onChange);

// observer callback
function onChange(changes) {
  changes.forEach(entry => {
    // check if image is visible
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src
    }
  });
}

// observe images
const lazyImages = document.querySelectorAll('.lazy--image')
[...lazyImages].forEach(el => observer.observe(el));

That's how you can easily implement a lazy loading logic right now on browsers supporting IntersectionObserver, the issue with this code snippet is we will re-assign the src of the iamges every time the re-enter the viewport, even when it's not necessary, for this we need to remove the target from the observer.

function onChange(changes) {
  changes.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src;
      // STOP OBSERVING IMAGE
      observer.unobserve(entry.target);
    }
  });
}

Now, we will stop tracking images we already checked.

It's important to mention that isIntersecting was added later to the spec, so in some browser versions it will be undefined, to be safe you can rely on the intersectionRatio property.

function onChange(changes) {
  changes.forEach((entry) => {
    // rely on intersectionRatio
    if (entry.intersectionRatio > 0) {
      entry.target.src = entry.target.dataset.src;
      observer.unobserve(entry.target);
    }
  });
}

Check in caniuse.com which browsers don't support this.

You might wonder why intersection ratios are important, the fact is that the usual example for this new interface are around lazy loading images, like I just did so it doesn't count as shaming, but let's say you want to trigger an animation on a visible element.

It doesn't make any sense to do this as soon as one pixel from the target enters the viewport, you might prefer to animate once a more decent portion of it is visible for the use, let's say half of it.

function onChange(changes) {
  changes.forEach((entry) => {
    if (entry.intersectionRatio > 0.5) {
      entry.target.classList.add('animate');
      observer.unobserve(entry.target);
    }
  });
}

This gives developers more granular control over when the actions are executed during observation.

Disconnect

You can always suspend the whole observation by doing observer.disconnect(), useful if for example, after some change in the DOM you know elements are not longer gonna be present.

Observers behavior

As I mentioned before, the second argument the observer constructor receives allows you to configure its behavior. The options this object supports are:

It's possible though not immediately probable you will need to alter these values. If you do, I recommend checking out the MDN page for intersection observers use.

Wrap-up

It's interesting to see vendors filling up the gaps between what we do on our projects against what the platform offers natively.

If you find yourself observing the DOM as you scroll to observe elements you should definitely consider intersection observers as a more performant solution as it immediately frees the main thread for its execution.

You can always add a polyfill for legacy browsers that don't support or for partial implementations.

Updates

9 AUG 2016 — The article was updated to reflect the last changes in the spec.