IntersectionObserver in WebKit

Web authors often need to know when a particular element on a page becomes visible. The element could be an ad or a video whose viewability we want to compute. Or we might want to defer loading an image until it is visible. A common way to solve this problem is using polling, periodically computing the position of an element with respect to the viewport. However, polling is inefficient, wastes battery life, and doesn’t work for cross-origin content. The IntersectionObserver API offers a better solution to this problem, and is now available in Safari Technology Preview, macOS 10.14.4 beta, and iOS 12.2 beta.

API Overview

Each IntersectionObserver has a set of target elements and observes the intersection of these targets with a particular root element or with the viewport. When the intersection of a target and root crosses a specified threshold, the observer invokes a callback. Here’s a simple example to get started:

var intersectionCount = 0;
function callback(entries) {
    entries.forEach((entry) => {
        if (entry.isIntersecting)
            intersectionCount++;
    });
}
var observer = new IntersectionObserver(callback);
observer.observe(target);

In the example above, we haven’t specified a root element so we’re observing intersections with the viewport. We pass the IntersectionObserver constructor the callback function we want it to use. We’ll get a callback as soon as any portion of the target intersects the viewport, and we’ll get another callback as soon as the target has no intersection with the viewport.

What if we want to know when all or most of an element is visible, not just any portion of it? We can specify an intersection threshold when constructing the observer:

observer = new IntersectionObserver(callback, { threshold: 0.95 } );

Now, we’ll get a callback once 95% of a target intersects the viewport and another callback once the intersection ratio drops below 95%.

Extending this idea, we can provide a list of thresholds that we’re interested in:

observer = new IntersectionObserver(callback, {
    threshold: [ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1 ] });

Here’s a live demo that uses a list of thresholds. Scroll the target into view, and then slowly scroll it out of view and back into view, and notice observations getting logged as the intersection ratio crosses a threshold.

The same idea works for a target that’s inside an iframe (demo). In this case, there are two ways to scroll the target out of view — scrolling the window so that the iframe is out of view, and scrolling the iframe itself — and both will trigger IntersectionObserver callbacks.

Suppose we want advance notice that an element is about to become visible. The IntersectionObserver constructor lets us specify a margin by which to expand the root when computing intersections.

observer = new IntersectionObserver(callback, { rootMargin: '100px 0px' } );

When computing intersections, the viewport will be expanded by 100 pixels at the top and bottom, and will not be expanded on the left or right.

Using negative root margin values, we can shrink the root when computing intersections. For example, in this demo, we shrink the viewport by 50px on all four sides:

observer = new IntersectionObserver(callback, {
    threshold: [ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1 ],
    rootMargin: '-50px'
});

Finally, we can specify a particular element to intersect with rather than the viewport:

observer = new IntersectionObserver(callback, { root: someElement } );

Let’s now take a look at the entries that get passed to the callback each time it is invoked. Suppose we want to to compute how long an element is at least 80% visible:

observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting)
            entry.target.startTime = entry.time;
        else if (entry.target.startTime)
            entry.target.duration = entry.time - entry.target.startTime;
    });
}, { threshold: 0.8 });

Each intersection observer entry is a dictionary containing details about the intersection between a particular target and the root. This includes a timestamp, the intersection rectangle, and an intersection ratio that represents the fraction of the target that’s contained within the intersection rectangle.

More examples

Lazy image load

Suppose we have a long page with many images. Rather than loading all these images at once, including those that are off-screen, we can start with low-resolution placeholder images, and defer loading high-resolution versions until an image starts to intersect (or, using rootMargin, is about to intersect) with the viewport. Here’s a demo illustrating this approach. To make it easier to see the high-resolution images getting loaded, the demo doesn’t use rootMargin, and waits 500ms after an image starts to intersect the viewport before switching to a high-res version.

Triggering an animation when an element becomes visible

To ensure that an animation only starts once the animated content is visible, we can trigger the animation inside an IntersectionObserver callback:

function intersectionCallback(entries) {
    for (let entry of entries) {
        if (entry.isIntersecting) {
            entry.target.style.transform = 'translateX(20px)';
            entry.target.style.transition = 'transform 2s';
        } else {
            entry.target.style.transform = 'none';
            entry.target.style.transition-property = 'none';
        }
    }
}

Every time a target element starts intersecting the viewport, we’ll start an animation to slide it to the left, and we’ll put the element back to its original position once it no longer intersects. This demo has a few examples of animations that get triggered by intersection observations.

Feedback

We’re excited to have IntersectionObserver available in Safari Technology Preview, macOS 10.14.4 beta, and iOS 12.2 beta! Please try it out and file bugs for any issues you run into.