Customize pinned element with IntersectionObserver

Customize pinned element with IntersectionObserver

Learn how to detect when "sticky" element gets pinned

Recently I run into an interesting issue: I wanted to customize a pinned element (position: sticky), at the moment when it gets pinned. It was a filter section, which I wanted to wrap up into a small bar. Thanks to that, users on mobiles have easy access to filters, even if they scrolled down to the very end of the items list.

The question is: how to detect when the "sticky" element gets pinned? The answer is: IntersectionObserver.

Let's consider the super simple case: we have .filters-panel div, containing the filter form. When a user scrolls down, the filters go out of view, we'd like to add pinned CSS class to .filters-pannel. The .observer-point element below is not here by accident. We'll discuss its purpose later.

<div class="filters-panel"> <!-- When it goes out of the viewport, add `pinned` class -->
  <form class="filters-form">
    <!-- FILTERS -->
  </div>
  <button class="show-filters">Show filters</button> <!-- Hidden by default -->
</div>
<div class="observer-point"></div>

Thanks to the pinned class, we can set the filters panel to hide the filter form, show the button Show filters and do some other styling. Roughly, the CSS (actually, let's go with Sass) could look like the one below.

.filters-panel
  +mobile
    &.pinned
      position: sticky
      form.filters-form
        display: none
      button.show-filters
        display: block

.observer-point
  height: 0px

You got the idea, right? So now, let's get this working!

Detect disappearing element with IntersectionObserver

Probably the simplest JS for this looks like this:

const observer = new IntersectionObserver((entries) => {
  const sortingPanel = document.querySelector(".filters-panel")
    if (entries[0].isIntersecting) {
       sortingPanel.classList.remove("pinned")
    }
    else  {
      sortingPanel.classList.add("pinned")
    }
})

observer.observe(document.querySelector('.observer-point'))

It could be explained like this: *when the .observer-point element disappears, add pinned class to .filters-panel. Otherwise, remove it. *

Pretty simple, but why does it observe some .observer-point instead of .filters-pannel directly? Basically (depending on the implementation), without .observer-point, it may run into an infinity loop like: "Filters element disappears? Pin it! Oh, it is shown now... So "unpin it". Disappeared? Pin it!" etc.

This issue causes flickering - an ugly one! A simple trick of observing an additional element like .observer-point solves the issue. Remember to set its height to 0px. Otherwise, it won't work.

Fixed navbar above? No problem!

Last but not least. When you have another element fixed to the top (most likely a navbar), just initialize the IntersectionObserver with margin-top set to the negative navbar's height. You can do this by passing rootMargin property as a second argument.

const observer = new IntersectionObserver((entries) => {
  ...
}, { rootMargin: '-60px 0px 0px 0px' }) // assuming the navbar's height is 60px

With this trick, .observer-point will be detected before disappearing under the navbar.

I believe that's the simplest, but practical example of the use of IntersectionObserver. It has much more functionalities, you can read about them in the documentation.

I think it's a bit less intuitive than using scroll event and doing calculations on the fly... But it's more elegant and efficient. scroll event is so spammy - it's better to avoid it when you can 🙂