The Intersection Observer API has been around for a few years now and has pretty good browser support.

It has some nice options which can be intimidating or simple depending on what you want to do with it.

In my case, I had a horizontal scrolling list of items in which I wanted to highlight the center most item.

This is the final product.

The first instinct we might have is to use an intersection observer, which is a way for the browser to tell us when some element intersects with (or not) another.

let centerObserver = new IntersectionObserver(
  function (entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add("focused");
      } else {
        entry.target.classList.remove("focused");
      }
    });
  },
  // options
  {}
);

Where entries are an array of IntersectionObserverEntry corresponding to the items we tell the observer to observe, like this:

let items = document.querySelectorAll(".item");
items.forEach(item => centerObserver.observe(item));

When the item is intersecting ie visible, we add a .focused class to it.

But the problem with this observer is that it defaults to observing the items position based with regard to the viewport instead of the container element.

To fix that we can pass the root element options in the second argument to the IntersectionObserver.

// ....
// options
{
  root: document.querySelector(".container");
}

Pretty much there, except that all the items in view getting the .focused class, and this is where the rootMargin option comes to play.

rootMargin enables one to enlarge (or restrict) the span of the area of the container. In this case we want the latter.

Let’s say we want restrict the intersection area of the container to the middle 40%, that would be {rootMargin: "0 -30% 0 -30%"}

Notice the negative margin to eat into the container instead of add to it.

let root = document.querySelector(".container");
// the options look so far like this
{ root: root,
  rootMargin: "0 -30% 0 -30%"
}

This still depends on the items fitting within the remaining 40% width and not being bellow 20%, otherwise we would have two focused items.

One possible fix is to use some dynamic dimensions for the rootMargin. What we want is to leave enough (or slightly more) space for one item to intersect.

let itemWidth = items[0].offsetWidth;
let containerWidth = root.offsetWidth;
let marginWidth = (containerWidth - itemWidth) / 2;
//...
{
  rootMargin: `0 ${marginWidth}px  0 ${marginWidth}px`;
}

This will always leave enough space for one item only.

One last problem (last one I promise) is that items in beginning and in the end don’t have space to scroll to the midway point, so will never get .focused.

A possible solution is to add a margin-left for the first item and a margin-right for the last item of marginWidth, then fixing the visual offset by adjusting the container’s scrollLeft by the same amount.

One of the options of IntersectionObserver is threshold which is how much of the item should be visible to be considered isIntersecting, it defaults to 1 ie 100% but it would be nice if the item got highlighted as soon as it takes more space in the area of interest than any other item, so I added {threshold: 0.51}. That is once it has at least 51% shown, it gets focused.

The full snippet is in the embedded Codepen.

I hope you learned something new and saved some time.

Thank you for reading, don’t forget to follow for more.