Managing media and animations with Intersection­­­Observer

A photo of a large and well-traveled, perpendicular street intersection in Kuala Lumpur, Malaysia.

I use quite a few animations on this site. Most of them are small, and require user interaction. Others autoplay, and repeat, but I don't think they're very resource intensive. Still, there's no point to letting an animation run when it isn't visible to the reader. This is where the Intersection Observer API comes in handy.

IntersectionObserver watches for and reports when an element intersects with an ancestor element or the document's viewport. It's an asynchronous API, similar to MutationObserver. Use it in place of older techniques for checking intersections, such as calling Element.getBoundingClientRect() in response to a scroll event.

I use MutationObserver on this site to manage CSS animations in SVG images. I've also used it in other projects to play and pause media.

Let's dig in.

Creating an intersection observer

Create an intersection observer using IntersectionObserver() constructor. It accepts up to two arguments.

  • callback, a function that receives an array of IntersectionObserverEntry objects as its first argument and the current observer object as its second; and
  • options, a dictionary that indicates the root element, rootMargin, and/or the threshold at which IntersectionObserver.isIntersecting becomes true.

Only the first argument, a callback function, is required.

/*
Create a new IntersectionObserver instance, passing a callback as its argument
*/
const videoObserver = new IntersectionObserver(playVideoCallback);

/* Observe the video element */
videoObserver.observe(document.querySelector('video'));

Once you create an IntersectionObserver instance, you can use the observe() method to watch one or more elements on the page. In this example, once our video becomes visible in the viewport, the video will play. When the video moves outside of the viewport, it will stop playing.

You can watch multiple elements using the sameIntersectionObserver instance. Call the observe() method for each element you wish to track.

const videos = document.querySelectorAll('video');
videos.forEach((v) => videoObserver.observe(v));

When each observed video intersects with the viewport, the observer invokes the callback function.

Taking action with a callback

I haven't yet defined the callback function, playVideoCallback. Let's do so.

/* Define the callback function */
function playVideoCallback(entries) {
  /* entries is an array, so we can use Array methods. */
  entries.forEach((entry) => {
    if(entry.isIntersecting){
      entry.target.play();
    } else {
      entry.target.pause();
    }
  });
}

Although this callback function receives two arguments, you only need to define the first one. Again, this argument is an array of IntersectionObserverEntry objects. That means we can use Array.prototype.forEach() to iterate through each item in entries.

Each IntersectionObserverEntry contains an isIntersecting property that indicates whether the observed element intersects with the intersection root. Each entry also has atarget property, which is the observed element.

Setting an intersection root

In some cases, you'll want to determine whether an element has entered the bounding box of an ancestor element instead of the document viewport. Set the root option for the IntersectionObserver constructor to accomplish this. Its value must be an element selected using a method of document such as getElementById or querySelector.

const intersectionRoot = document.getElementById('#scrollableAncestor');
const elementObserver = new IntersectionObserver(playVideoCallback, {root: intersectionRoot});
elementObserver.observe(document.querySelector('video'));

Intersection root elements should be scrollable. The body element is usually scrollable by default as long as the length of its content exceeds its bounds. For other elements, you'll need to do two things:

  1. set height or width properties; and
  2. set overflow to auto or scroll.

Note that when the root option isn't specified, the intersection root is the document viewport.

Intersection root elements must be an ancestor of the observed element. IntersectionObserver is not a way to test the overlap or collision of sibling elements.

Adjusting the size of the intersection bounding box

Changing the rootMargin property adjusts the intersecting area of the root element's bounding box.

const intersectionRoot = document.getElementById('#scrollableAncestor');
const options = {
  root: intersectionRoot,
  rootMargin: '0px 0px 60% 0px',
}
const elementObserver = new IntersectionObserver(playVideoCallback, options);
elementObserver.observe(document.querySelector('video'));

Its syntax is similar to the CSS margin property. Values are ordered clockwise from the top. There are, however, two important differences between the CSS property and rootMargin.

  1. rootMargin values must be expressed as pixels, as a percentage, or both.
  2. rootMargin values are parsed as JavaScript strings, so they must be quoted.

Valid values include '0px 0px 60% 0px', ''5% 0% 0% 0%' and '1000px 0px'. Unit values such as vh, rem or em trigger a SyntaxError or a DOMException error, depending on the browser.

Setting the rootMargin property indicates how much space to add to or remove from the bounding box before computing an intersection. Say the intersection root is 800 pixels tall and the rootMargin value is 0px 0px 60% 0px, as above. The browser will calculate the intersection based on an intersection root height of about 1330 pixels — 60% more than the bounding box height. View a rootMargin demo.

If you don't set the rootMargin property, its value is 0px 0px 0px 0px.

Adding a threshold to the IntersectionObserver target

The threshold option applies to the observed element, rather than the intersection root. It must be a number or an array of numbers between 0 and 1. It indicates how much of the observed element's bounding box should overlap with the intersection root's bounding box before the callback gets invoked.

In the following example, our callback gets invoked once 75% of the observed element intersects with intersectionRoot.

const intersectionRoot = document.getElementById('#scrollableAncestor');
const options = {
  root: intersectionRoot,
  threshold: 0.75,
};
const videoObserver = new IntersectionObserver(playVideoCallback, options);
const video = document.querySelector('video');
videoObserver.observe(video);

Now the video won't begin playing until 75 percent of it becomes visible. Keep in mind that the video also won't stop playing until 75 percent of it no longer intersects with the intersectionRoot bounding box.

Perhaps you specify multiple values for threshold of [0.1, 0.5, 1], as shown in the following example.

const videoObserver = new IntersectionObserver(playVideoCallback, { threshold: [0.1, 0.5, 1] });

You can then invoke different behaviors from your callback function, based on the value of IntersectionObserverEntry.intersectionRatio

function playVideoCallback(entries, observer) {
  entries.forEach((entry) => {
    if(!entry.isIntersecting) return;

    if(entry.intersectionRatio < 0.5) {
      console.log('not yet halfway');
    } else if(entry.intersectionRatio > 0.5 && entry.intersectionRatio < 1) {
      console.log('halfway');
    } else {
      console.log('fully visible!');
    }
  });
}

IntersectionObserver is well-suited to creating features triggered by user scrolling. Use it to load more content when the reader nears the bottom of the page (infinite scroll) or trigger animations when a <div> element becomes visible in the viewport. You might also use IntersectionObserver to lazy load images or measure how long an element remains in the viewport.

Learn more

Photo credit: Deva Darshan from Pexels.

Subscribe to the Webinista (Not) Weekly

A mix of tech, business, culture, and a smidge of humble bragging. I send it sporadically, but no more than twice per month.

View old newsletters