A Few Functional Uses for Intersection Observer to Know When an Element is in View

Avatar of Preethi
Preethi on

You might not know this, but JavaScript has stealthily accumulated quite a number of observers in recent times, and Intersection Observer is a part of that arsenal. Observers are objects that spot something in real-time — like birdwatchers going to their favorite place to sit and wait for the birds to come.

Different observers observe different things (not everyone watches hawks).

The very first observer I came to know was the Mutation Observer that looks for changes to the DOM tree. It was a one-of-a-kind at the time, but now we have many more observers.

Intersection Observer observes the “intersection” (i.e. the passing across) of an element through one of its ancestor elements or the area on screen where the page is visible (aka the viewport).

It’s sort of like watching a train pass through a station. You can see when the train comes in, when it leaves, and how long it was stationary.

Knowing when an element is about to come into view, if it has gone out of view, or how long it’s been since it came into view all have useful applications. So, we’ll see some of those use cases now — right after seeing the code for creating an IntersectionObserver object by way of the Intersection Observer API.

A quick overview of IntersectionObserver

The Intersection Observer API has already gained wide support at the time of this writing.

This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.

Desktop

ChromeFirefoxIEEdgeSafari
5855No1612.1

Mobile / Tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
12212312212.2-12.5

But if you want to check whether Intersection Observer is supported while you’re working with it, you could see if the property IntersectionObserver exists in the window object:

if(!!window.IntersectionObserver){}
/* or */
if('IntersectionObserver' in window){}

OK, now for a look at the object creation:

var observer = new IntersectionObserver(callback, options);

The IntersectionObserver object’s constructor takes two parameters. The first one is a callback function that’s executed once the observer notices an intersection and has asynchronously delivered some data about that intersection.

The second (optional) parameter is options, an object with information to define what’s going to be the “intersection.” We may not want to know when an element is about to come into view, but only when it’s fully visible. Something like that is defined through the options parameter.

Options has three properties:

  • root – The ancestor element/viewport that the observed element will intersect. Think of it as the train station that the train will intersect.
  • rootMargin – A perimeter of the root element, shrinking or growing the root element’s area to watch out for intersection. It’s similar to the CSS margin property.
  • threshold – An array of values (between 0 and 1.0), each representing the distance an element has intersected into or crossed over in the root at which the callback is to be triggered.

Let’s say our threshold is 0.5. The callback is triggered when the element is in or passes its half-visible threshold. If the value is [0.3, 0.6], then the callback is triggered when the element is in or passes its 30% visible threshold and also, its 60% visible threshold.

That’s enough of the theory now. Let’s see some demos. First up, lazy loading.

Use Case 1: Lazy loading images

See the Pen
Intersection Observer – Lazy Load
by Preethi Sam (@rpsthecoder)
on CodePen.

To see the loading at the mark, view this webpage since the embedded demo doesn’t show that.

CSS-Tricks has thoroughly covered lazy loading in before and it’s typically done like this: display a lightweight placeholder where images are intended, then swap them out for the intended images as they come (or are about to come) into view. Believe me, there’s nothing lazy about implementing this — that is, until we get something native to work with.

We’ll apply the same mechanics. First, we’ve a bunch of images and have defined a placeholder image to display initially. We’re using a data attribute carrying the URL of the original image to be shown that defines the actual image to load when it comes into view.

<img src="placeholder.png" data-src="img-1.jpg">
<img src="placeholder.png" data-src="img-2.jpg">
<img src="placeholder.png" data-src="img-3.jpg">
<!-- more images -->

The rest is scripting.

let observer = new IntersectionObserver(
(entries, observer) => { 
  entries.forEach(entry => {
    /* Here's where we deal with every intersection */
  });
}, 
{rootMargin: "0px 0px -200px 0px"});

The callback function above is an arrow function (though you can use a normal function instead).

The callback function receives two parameters: a set of entries carrying the information about each intersection and the observer itself. Those entries can be filtered or looped through so we can then deal with the intersection entries that we want. As for the options, I’ve only provided the rootMargin value, letting the root and threshold properties fall into their default values.

The root default is the viewport and threshold default is 0 — which can be roughly translated to “ping me the very moment that the element appears in the viewport!”

The oddity, though, is that I reduced the viewport’s observation area by two hundred pixels at the bottom using rootMargin. We wouldn’t typically do this for lazy loading. Instead, we might increase the margin or let it default. But reducing isn’t something we would usually do in this situation. I did it only because I want to demonstrate the original images loading at the threshold in the observed area. Otherwise, all the action would happen out of view.

When the image intersects the viewport’s observer area (which is 200px above the bottom in the demo), we replace the placeholder image with the actual image.

let observer = new IntersectionObserver(
(entries, observer) => { 
entries.forEach(entry => {
    /* Placeholder replacement */
    entry.target.src = entry.target.dataset.src;
    observer.unobserve(entry.target);
  });
}, 
{rootMargin: "0px 0px -200px 0px"});

entry.target is the element observed by the observer. In our case, those are the image elements. Once the placeholder is replaced in an image element, we don’t have to observe it anymore, so we call the observer’s unobserve method for it.

Now that the observer is ready, it’s time to start observing all the images by using its observer method:

document.querySelectorAll('img').forEach(img => { observer.observe(img) });

That’s it! we’ve lazy loaded the images. Onto the next demo.

Use Case 2: Auto-pause video when it’s out of view

Let’s say we’re watching a video on YouTube and (for whatever reason) we want to scroll down to read the comments. I don’t know about you, but I don’t usually pause the video first before doing that, which means I miss some of the video while I’m browsing.

Wouldn’t it be nice if the video paused for us when we scroll away from it? It would be even nicer if the video resumed when it’s back in view so there’s no need to hit Play or Pause at all.

Intersection Observer can certainly do that.

See the Pen
IntersectionObserver: auto-pause out of view video
by Preethi Sam (@rpsthecoder)
on CodePen.

Here’s our video in the HTML:

<video src="OSRO-animation.mp4" controls=""></video>

Here’s how we toggle between play and pause:

let video = document.querySelector('video');
let isPaused = false; /* Flag for auto-paused video */
let observer = new IntersectionObserver((entries, observer) => { 
  entries.forEach(entry => {
    if(entry.intersectionRatio!=1  && !video.paused){
      video.pause(); isPaused = true;
    }
    else if(isPaused) {video.play(); isPaused=false}
  });
}, {threshold: 1});
observer.observe(video);

Before I show you how we’re pausing and playing the video during each intersection (i.e. entry), I want to bring your attention to the threshold property of the options.

Th threshold has a value of 1. Both root and rootMargin will default. This is the same as saying, “hey, let me know as soon the element is fully visible on the viewport.”

Once the intersection happens and the callback is triggered, we pause or play the video based on this logic:

A flow chart for toggling play and pause on a video, where if video not fully visible and not paused, then isPaused is true. But if video was auto-paused, then IsPaused is false.

I have not called unobserve for the video, so the observer keeps observing the video and pauses every time it goes out of view.

Use Case 3: See how much content is viewed

This can be interpreted and implemented in many ways depending on what your content is and the way you prefer to measure how much of it has been viewed.

For a simple example, we’ll observe the last paragraph of every article in a list of articles on a page. Once an article’s last paragraph becomes fully visible, we will consider that article read — like how we might say that seeing the last coach of a train counts as having seen the whole train.

Here’s a demo that shows two articles on a page, each containing a number of paragraphs.

See the Pen
IntersectionObsever: content viewed
by Preethi Sam (@rpsthecoder)
on CodePen.

Our simplified HTML is something like this:

<div id="count"><!-- The place where "number of articles viewed" is displayed --></div>

<h2>Article 1</h2>
<article>
  <p><!-- Content --></p>
  <!-- More paragraphs -->
</article>
<h2>Article 2</h2>
<article>
  <p><!-- Content --></p>
  <!-- More paragraphs -->
</article>
<!-- And so on... -->
let n=0; /* Total number of articles viewed */
let count = document.querySelector('#count');
let observer = new IntersectionObserver((entries, observer) => { 
  entries.forEach(entry => {
    if(entry.isIntersecting){
      count.textContent= `articles fully viewed - ${++n}`; 
      observer.unobserve(entry.target);
    }
  });
}, {threshold: 1});

document.querySelectorAll('article > p:last-child').forEach(p => { observer.observe(p) });

During each intersection — the full view of the last paragraph of an article — we’re incrementing a count: n, that represents the total number of articles read. Then we display that number above the list of articles.

Once we’ve counted in an intersection of the last paragraph, it doesn’t need to be observed anymore, so we call unobserve for it.

Thanks for observing along!

That’s it for the examples we’re going to look at together for this post. You probably get the idea of how using it is, to be able to observe elements and trigger events based on where they intersect the viewport.

That said, it’s worth using caution when making visual changes based on the intersection data obtained through the observer. Sure, Intersection Observer is hassle free when it comes to logging intersection data. But when it’s being used to make onscreen changes, we need to ensure the changes aren’t lagging, which is a possibility because we’re basically making changes based on data retrieved asynchronously. That might require a little bit of time to load.

As we saw, each intersection entry has a set of properties conveying information about the intersection. I didn’t cover all of them in this post, so be sure to review them.