Scrollspy using Intersection Observer

I recently had to make a Scrollspy for my personal web portfolio. I wanted to use Intersection Observer as it is widely supported now. Unfortunately, it isn’t very simple and there isn’t much on the internet on how to make it work. So, I decided to write a small article to help out others looking for the same.

Scrollspy is a navigation mechanism used by Bootstrap. As per the docs,


Automatically update navigation or list group components based on scroll position to indicate which link is currently active in the viewport.

Basically, it is a piece of JavaScript that allows your headers and lists to know which element is currently on the screen. This is usually used to highlight the active section in the navigation bar.

Intersection Observer

Earlier, the effect was achieved using a scroll event listener which would check all the sections one by one on every scroll and then update the section currently in the viewport to be active. Luckily , we have the new IntersectionObserver API which makes the task extremely simpler and more efficient.

IntersectionObserver is used to monitor a specific part of the viewport to check when specified DOM nodes intersect with that region. Whenever the node enters or leaves the region, a callback is executed.

If you don’t know about the IntersectionObserver API, check out this excellent article. The key benefit of intersection observer is that unlike scroll event listener, it is asynchronous.

Parameters and options

IntersectionObserver API takes the following options:

  1. Intersection root: The region which is to be monitored for intersection with specified nodes. It defaults to the viewport but can also be set to a DOM node.
  2. Root margin: By default, entire root is monitored. But we can specify margins around the root to monitor a larger (or smaller if margins are negative) region than the root.
  3. Threshold: A value between 0 and 1 which specified how much part of the target element(s) should be in the intersection root to trigger the callback function. 0 means as soon as even one pixel enters the region or as soon as the entire node leaves the region. 1 means that the entire node should be in the region. Multiple values can be provided in form of an array.
  4. Callback: A function to be called whenever an intersection takes places.

First step is to create an observer using the above options. Then target elements are registered with the intersection observer. If any of the elements intersects with the specified region within the threshold values, callback is invoked. As per MDN, Syntax for the callback is:

let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.time

entries paramter contains entry for all the observed nodes. To find out which entry is intersecting with the root, use entry.isIntersecting property.

The technique

Let us say we have a page with multiple sections and we need to find out the one that is currently of main focus. Here we do the following:

  1. Keep the threshold to 0 (the default value). That means whenever the first pixel of any section enters the viewport or the last pixel of any section leaves the root, the callback will be executed.
  2. Intersection root is the entire viewport by default. We want the intersection to be observed at horizontal line. We can do that by adding a margin to the root and reducing the observed area to a horizontal line. To set it exactly middle of the screen, set rootMargin to -50% 0px
  3. Get a list of all the sections in the page and connect them to the intersection observer.
  4. Whenever an intersection occurs, find out which element caused the intersection. To do that, use entry.isIntersecting property.

Code is pretty straightforward:

window.onload = () => {
const sections = document.getElementsByTagName("section");
const label = document.getElementById("section-name");

const observer = new IntersectionObserver((entries) => {
for(const entry of entries)
label.innerHTML =;
rootMargin: "-50% 0px"
for (let i = 0; i < sections.length; i++)

You can see it in action here:

Frontend engineer at GrowthDay | All things Web