@MaximeHeckel

Scrollspy demystified

March 9, 2021 / 19 min read

Last Updated: January 15, 2023

Many of you have emailed or DM'ed me asking about how I implemented my table of content component, the little list of titles you'll see appear on the left gutter once you scroll a bit more down, and how I manage to highlight in that list the title of the current paragraph in view. Well, by popular demand, I finally took the time to write about this specific piece of code, and also use this as an opportunity to deep dive into the inner workings of the main trick behind it named...

Scrollspy

In this article, we'll analyze together an elegant way to implement a Scrollspy, how to abstract it with a Hook for your React project, and finally, I'll add some tips at the end on how you can integrate it with a markdown or MDX based blog to build a little table of content component similar to mine.

It's all about knowing what's intersecting the viewport

Scrollspy is a "technique" that's used to keep track of the content of the user's viewport and highlight the corresponding navigation item.

There are many ways to implement a Scrollspy, some of them are very complicated and involve a lot of maths. But you know me, I always prefer simple yet elegant solutions when it comes to tackling UI-related problems; it's just more satisfying that way ✨. In this case, that elegant solution is called the Intersection Observer API.

What is the Intersection Observer API?

In a nutshell, it's a little tool that you can use in your Javascript frontend code to detect whether a given DOM Node intersects with the document's viewport or another parent element.

How can it be used in the context of a Scrollspy?

As mentioned above, the aim of a Scrollspy is being able to keep track of what's currently "in view" for the user, thus what's intersecting with the viewport. In this blog post right now, if your window is big enough to display the table of content, you should see that the title It's all about knowing what's intersecting the viewport being highlighted since its corresponding part is currently "in view". This is because the DOM element wrapping this first part is currently "intersecting" with our viewport, and also because I built my table of content component to highlight the title corresponding to whichever section is intersecting.

Sounds pretty straightforward so far right? Well, it because that's pretty much all there is behind this kind of implementation of a Scrollspy. However, there can be more nuances such as:

  1. ArrowAn icon representing an arrow
    What if 2 elements intersect at the same time? Should we highlight both corresponding titles?
  2. ArrowAn icon representing an arrow
    How to take into account something like a fixed header?

Regarding the first question, the implementation I introduce here only considers that one section can be highlighted at a time, thus the first section to intersect will be the one highlighted.

To answer the second one, we're lucky: the Intersection Observer API allows us to pass a rootMargin option. This option adds margins around the root element/the viewport, before computing whether a section is intersecting or not.

For example, adding a rootMargin of "-100px 0px 0px 0px" will add a margin top for our viewport of -100px thus making the intersection of a given element end 100px "earlier".

To help visualize these 2 nuances listed above, I built this little widget below:

  • ArrowAn icon representing an arrow
    The mini browser lets you scroll a mock web page with different sections.
  • ArrowAn icon representing an arrow
    Each section will be highlighted whenever it intersects with the viewport using the Intersection Observer API.
  • ArrowAn icon representing an arrow
    You can modify the "offset" or rootMargin by adding some negative margin-top to see the intersection between the section and the viewport start/end earlier the more offset you add.
  • ArrowAn icon representing an arrow
    You can modify the height of the sections to see how the first section in view is always the one being highlighted.

Why use this rather than a scroll event and detecting if the element's scroll position fits in the viewport?

Well, there are 2 main reasons behind that:

  1. ArrowAn icon representing an arrow
    Performance: scroll event listeners run on the main thread whereas Intersection Observers do not. Thus using scroll events to continuously keep track of the current section in view is less performant and you'd probably end up needing to add some kind of throttling mechanism. @AggArvanitakis covers this in-depth in his blog post comparing both Intersection Observers and Scroll event performance.
  2. ArrowAn icon representing an arrow
    Finding if an element's scroll position fits within the viewport requires ~~a bit of~~ maths and I didn't like it 🤢. You can see by yourself with the code snippet below which is way harder to parse than what we're about to see.

Implementation of isInView, a function that returns true if an element is in view

1
const isInView = (element: Element, offset: number = 0) => {
2
const rect = element.getBoundingClientRect();
3
4
const scrollTop =
5
document.documentElement.scrollTop || document.body.scrollTop;
6
7
const scrollBottom = scrollTop + window.innerHeight;
8
9
const elemTop = rect.top + scrollTop;
10
const elemBottom = elemTop + element.offsetHeight;
11
12
const isVisible =
13
elemTop < scrollBottom - offset && elemBottom > scrollTop + offset;
14
return isVisible;
15
};

Abstracting the implementation in a React Hook

Now that we've taken a look at how we can leverage the Intersection Observer API to implement a Scrollspy, let's abstract all the implementation details in a little React Hook so it can be easily used in any current or future project.

First steps with Intersection Observers

Let's take a look at the code necessary to instantiate a new Intersection Observer in Javascript and have it observe a set of elements:

Basic usage of the Intersection Observers API

1
const observer = new IntersectionObserver((entries) => {
2
entries.forEach((entry) => {
3
console.log(entry.isIntersecting); // returns true if this entry is intersecting with the viewport
4
console.log(entry.intersectionRatio); // returns a number between 0 and 1 representing the ratio of the element intersecting with the viewport
5
});
6
});
7
8
const targetElements = document.querySelectorAll('section');
9
10
observer.observe(targetElements);

As you can see, there're 2 main things to do to get started:

  1. ArrowAn icon representing an arrow
    Create the Intersection Observer and pass a callback function to it. That callback takes 2 arguments entries and observer but we only need to use the first one in our use-case. Entries is an array of objects where each object describes the intersection of one of the elements that we're observing.
  2. ArrowAn icon representing an arrow
    Start observing 🔍! For that, we need to create an array of elements to observe and call observer.observe(...).

That's it! You now know how to use the Intersection Observer API to observe how a set of elements intersects with the viewport 🎉!

Building an efficient Hook

I'm sure there are many ways to abstract this but building an efficient Hook and avoid instantiating Intersection Observers all over the place can be pretty challenging.

First, we need to create our Intersection Observer as we did above and wrap it in a useRef Hook. This way we can keep track of the state of any intersection across rerenders and also if we were to update our Intersection Observer, we would not trigger a rerender.

The second key step for our Hook implementation is to know when we should start observing. For that, we can use useEffect so we can start observing as soon as the component using our Hook mounts:

First iteration of our useScrollspy Hook

1
const useScrollspy = (elements: Element[]): [number] => {
2
const observer = React.useRef<IntersectionObserver>(
3
new IntersectionObserver((entries) => {
4
// find the index of the section that is currently intersecting
5
const indexOfElementIntersecting = entries.findIndex((entry) => {
6
// if intersection > 0 it means entry is intersecting with the view port
7
return entry.intersectionRatio > 0;
8
});
9
10
// TODO store the value of indexOfElementIntersecting
11
})
12
);
13
14
React.useEffect(() => {
15
// observe all the elements passed as argument of the hook
16
elements.forEach((element) => observer.current.observe(element));
17
18
// disconnect the observer once the component unmounts;
19
return () => observer.current.disconnect();
20
}, [elements]);
21
22
// TODO return the index of the element in the elements array that is currently intersecting
23
return [0];
24
};

This works perfectly, but a few things could go wrong:

  1. ArrowAn icon representing an arrow
    accessing current directly as we do here to observe and disconnect our Intersection Observer is not safe. The current we access on mount is not guaranteed to be the same when unmounting (remember, we can update the ref without triggering a rerender).
  2. ArrowAn icon representing an arrow
    if we were to change the target elements our effect will run again and we'll start keeping track of the new elements which is great! But... we didn't stop keeping track of the older elements (since we didn't unmount). Thus to avoid this scenario from breaking our app, the best thing to do is to check for any existing Intersection Observers currently instantiated and disconnect them every time our effect runs:

Improved version of our useScrollspy Hook handling unwanted side effect

1
const useScrollspy = (elements: Element[]): [number] => {
2
const observer = React.useRef<IntersectionObserver>(
3
new IntersectionObserver((entries) => {
4
// find the index of the section that is currently intersecting
5
const indexOfElementIntersecting = entries.findIndex((entry) => {
6
// if intersection > 0 it means entry is intersecting with the view port
7
return entry.intersectionRatio > 0;
8
});
9
10
// TODO store the value of indexOfElementIntersecting
11
})
12
);
13
14
React.useEffect(() => {
15
const { current: ourObserver } = observer;
16
// disconnect any previously instanciated observers
17
ourObserver.disconnect();
18
19
// observe all the elements passed as argument of the hook
20
elements.forEach((element) => ourObserver.observe(element));
21
22
// disconnect the observer once the component unmounts;
23
return () => ourObserver.disconnect();
24
}, [elements]);
25
26
// TODO return the index of the element in the elements array that is currently intersecting
27
return [];
28
};

Great, we're almost there! The last step now is to set what to return! For simplicity here, we're only going to return the index of the target currently intersecting with the viewport.

For that, we can initiate a new state to keep track of the index of the target currently intersecting, and set that state accordingly in the callback of our Intersection Observer:

Implementation of useScrollspy returning the index of the current target intersecting

1
const useScrollspy = (elements: Element[]): [number] => {
2
const [
3
currentIntersectingElementIndex,
4
setCurrentIntersectingElementIndex,
5
] = React.useState(-1);
6
7
const observer = React.useRef<IntersectionObserver>(
8
new IntersectionObserver((entries) => {
9
// find the index of the section that is currently intersecting
10
const indexOfElementIntersecting = entries.findIndex((entry) => {
11
// if intersection > 0 it means entry is intersecting with the view port
12
return entry.intersectionRatio > 0;
13
});
14
15
// store the value of indexOfElementIntersecting
16
setCurrentIntersectingElementIndex(indexOfElementIntersecting);
17
})
18
);
19
20
React.useEffect(() => {
21
const { current: ourObserver } = observer;
22
// disconnect any previously instanciated observers
23
ourObserver.disconnect();
24
25
// observe all the elements passed as argument of the hook
26
elements.forEach((element) => ourObserver.observe(element));
27
28
// disconnect the observer once the component unmounts;
29
return () => ourObserver.disconnect();
30
}, [elements]);
31
32
// return the index of the element in the elements array that is currently intersecting
33
return [currentIntersectingElementIndex];
34
};

A few things to note here:

  • ArrowAn icon representing an arrow
    using findIndex will return the index of the first target intersecting.
  • ArrowAn icon representing an arrow
    we set this index in a local state in that hook. Even though we may be calling set state over and over in that callback it will not impact performance since most of the time we'll be setting the same value that is already in the state.

Handling offsets and custom settings

Our Hook is now almost operational! One last thing to take into account is to have the ability to pass a custom root element and a custom root margin:

  • ArrowAn icon representing an arrow
    we need the first one so I can set a custom parent element that is not the main viewport, like for the playground below 😛 (it might also come in handy for you in the future)
  • ArrowAn icon representing an arrow
    we need the second one to allow our ScrollSpy to handle offset such as a header as we saw in the widget in the first part of this blog post.

Here's how I abstracted them:

Implementation of useScrollspy with options

1
const useScrollspy = (
2
elements: Element[],
3
options?: {
4
offset?: number;
5
root?: Element;
6
}
7
): [number] => {
8
const [
9
currentIntersectingElementIndex,
10
setCurrentIntersectingElementIndex,
11
] = React.useState(-1);
12
13
const rootMargin = `-${(options && options.offset) || 0}px 0px 0px 0px`;
14
15
const observer = React.useRef<IntersectionObserver>(
16
new IntersectionObserver(
17
(entries) => {
18
// find the index of the section that is currently intersecting
19
const indexOfElementIntersecting = entries.findIndex((entry) => {
20
// if intersection > 0 it means entry is intersecting with the view port
21
return entry.intersectionRatio > 0;
22
});
23
24
// store the value of indexOfElementIntersecting
25
setCurrentIntersectingElementIndex(indexOfElementIntersecting);
26
},
27
{
28
root: (options && options.root) || null,
29
// use this option to handle custom offset
30
rootMargin,
31
}
32
)
33
);
34
35
// ....
36
};

However, we now have a little problem: changing those options won't update our Intersection Observer 😱! But don't worry, working around this problem does not require too many changes:

  • ArrowAn icon representing an arrow
    Instead of creating our ref with our Intersection Observer, we simply set it as null first
  • ArrowAn icon representing an arrow
    Then, after disconnecting any pre-existing Intersection Oservers, we create a new one with the current set of options and point the current value of the ref to it.
  • ArrowAn icon representing an arrow
    We make sure to pass the options in the dependency array of our useEffect Hook so any change in options will disconnect the old observer and create a new one with the latest set of options.

Final implementation of useScrollspy

1
const useScrollspy = (
2
elements: Element[],
3
options?: {
4
offset?: number;
5
root?: Element;
6
}
7
): [number, Element[], number[]] => {
8
const [
9
currentIntersectingElementIndex,
10
setCurrentIntersectingElementIndex,
11
] = React.useState(-1);
12
13
const rootMargin = `-${(options && options.offset) || 0}px 0px 0px 0px`;
14
15
const observer = React.useRef<IntersectionObserver>();
16
17
React.useEffect(() => {
18
if (observer.current) {
19
observer.current.disconnect();
20
}
21
22
observer.current = new IntersectionObserver(
23
(entries) => {
24
// find the index of the section that is currently intersecting
25
const indexOfElementIntersecting = entries.findIndex((entry) => {
26
// if intersection > 0 it means entry is intersecting with the view port
27
return entry.intersectionRatio > 0;
28
});
29
30
// store the value of indexOfElementIntersecting
31
setCurrentIntersectingElementIndex(indexOfElementIntersecting);
32
},
33
{
34
root: (options && options.root) || null,
35
// use this option to handle custom offset
36
rootMargin,
37
}
38
);
39
40
const { current: ourObserver } = observer;
41
42
// observe all the elements passed as argument of the hook
43
elements.forEach((element) =>
44
element ? ourObserver.observe(element) : null
45
);
46
47
return () => ourObserver.disconnect();
48
}, [elements, options, rootMargin]);
49
50
return [currentIntersectingElementIndex];
51
};

It's now time to try out our new shiny Hook! Below you will find a playground containing the implementation of useScrollspy used to highlight the title of the corresponding section in view! (just like my table of content component)

To see our Hook in action you can:

  • ArrowAn icon representing an arrow
    scroll up and down the scrollable section in the playground and see the table of content component highlighting the proper title
  • ArrowAn icon representing an arrow
    try to modify the offset option
  • ArrowAn icon representing an arrow
    try to add or remove sections and see the Scrollspy updating accordingly.
import { styled } from '@stitches/react';
import React from 'react';
import './scene.css';

const Wrapper = styled('div', {
  display: 'flex',
  width: '300px',
  paddingTop: '56px',
});

const Content = styled('div', {
  height: '500px',
  overflowY: 'scroll',
  paddingRight: '8px',

  '&::-webkit-scrollbar': {
    WebkitAppearance: 'none',
    width: '8px',
  },

  '&::-webkit-scrollbar-track': {
    backgroundColor: 'hsla(222, 15%, 70%, 0.2)',
    borderRadius: '8px',
  },

  '&::-webkit-scrollbar-thumb': {
    borderRadius: '8px',
    backgroundColor: '#C4C9D4',
  },
});

const TableOfContent = styled('div', {
  width: '100px',
});

const List = styled('ul', {
  position: 'absolute',
});

const Section = styled('section', {
  height: '450px',
  width: '175px',
  display: 'block !important',
  background: '#16181D',
  borderRadius: '8px',
  color: '#C4C9D4',
  marginBottom: '24px',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  alignItems: 'center',
});

const useScrollspy = (elements, options) => {
  const [
    currentIntersectingElementIndex,
    setCurrentIntersectingElementIndex,
  ] = React.useState(-1);

  const rootMargin = `-${(options && options.offset) || 0}px 0px 0px 0px`;

  const observer = React.useRef();

  React.useEffect(() => {
    if (observer.current) {
      observer.current.disconnect();
    }

    observer.current = new IntersectionObserver(
      (entries) => {
        // find the index of the section that is currently intersecting
        const indexOfElementIntersecting = entries.findIndex((entry) => {
          return entry.intersectionRatio > 0;
        });

        // set this index to the state
        setCurrentIntersectingElementIndex(indexOfElementIntersecting);
      },
      {
        root: (options && options.root) || null,
        // use this option to handle custom offset
        rootMargin,
      }
    );

    const { current: currentObserver } = observer;

    // observe all the elements passed as argument of the hook
    elements.forEach((element) =>
      element ? currentObserver.observe(element) : null
    );

    return () => currentObserver.disconnect();
  }, [elements, options, rootMargin]);

  return [currentIntersectingElementIndex];
};

const Article = () => {
  const ids = ['part1', 'part2', 'part3'];
  const [elements, setElements] = React.useState([]);
  const [currentActiveIndex] = useScrollspy(elements, {
    root: document.querySelector('#demo-root'),
    offset: 20,
  });

  /**
   You can ignore this, it's only here so it plays nicely with SSR :)
  */
  React.useEffect(() => {
    const widgetElements = ids.map((item) =>
      document.querySelector(`section[id="${item}"]`)
    );

    setElements(widgetElements);
  }, []);

  return (
    <Wrapper>
      <TableOfContent>
        <List>
          {ids.map((id, index) => (
            <li
              key={id}
              style={{
                color:
                  currentActiveIndex === index
                    ? '#5786F5'
                    : '#C4C9D4',
              }}
            >
              Part {index + 1}
            </li>
          ))}
        </List>
      </TableOfContent>
      <Content id="demo-root">
        {ids.map((id, index) => (
          <Section key={id} id={id}>
            <p>Part {index + 1}</p>
            <p>Some Content</p>
          </Section>
        ))}
      </Content>
    </Wrapper>
  );
};

export default Article;

Markdown and MDX integration

We did it! 🎉 We now know how to implement a Scrollspy using Intersection Observer in a React Hook and how to leverage the output of the Hook to highlight the title of the current section "in-view"!

However, we only know how to do this for an arbitrary set of sections in a document. How shall we handle use-cases, like a blog post layout for instance, where we don't know the content/section we'll have to track?

I was facing this challenge myself not long ago. Each post of my blog is an individual Markdown/MDX file with raw text and maybe a bit of markup. I didn't want to hardcode the sections that my Scrollspy needed to track for each blog post:

  • ArrowAn icon representing an arrow
    It would be one extra thing to think about before releasing a new article.
  • ArrowAn icon representing an arrow
    I'd have to remember to update the set of sections every time I'd update a blog post.

My solution to this: sectionize my content with remark plugins

If you've built a Markdown/MDX-based blog before you've probably heard about remark. It's a little markdown processor that has a lot of plugins to automate some transformations in your markdown/MDX files.

I'm using remark here to automatically "sectionize" my Markdown/MDX posts with the help of 2 plugins:

  • ArrowAn icon representing an arrow
    remark-slug: This plugin parses your markdown file to find any h1, h2, h3 element you may have in your markdown, "slugifies" the text within that element, and adds it as an id.
  • ArrowAn icon representing an arrow
    a modified version of remark-sectionize: This plugin parses your markdown and will sectionize each part by wrapping both titles and the corresponding content under them in a <section/> tag. You can find the original implementation here. My version is slightly different as not only it will sectionize but it will also add an id tag to the section:

Modified version of remark-sectionize

1
// This snippet only contains the code I modified from remark-sectionize
2
3
function sectionize(node, ancestors) {
4
const id = node.data.id;
5
// some other code from remark-sectionize
6
7
const section = {
8
type: 'section',
9
depth: depth,
10
children: between,
11
data: {
12
hName: 'section',
13
// I only added the following to append ids to the section element
14
hProperties: {
15
id: `${id}-section`,
16
},
17
},
18
};
19
20
// some other code from remark-sectionize
21
}

I then added both plugins in my Markdown processor pipeline and magic 🪄 the output generated was exactly what was needed:

1
// Markdown Input
2
3
## My awesome content
4
5
Some code, some text
6
7
// DOM output
8
9
<section id="my-awesome-content-section">
10
<h2 id="my-awesome-content">My awesome content</h2>
11
<p>Some code, some text</p>
12
</section>

By clicking on the checkbox below, you can highlight the <section/> tags from this blog post, thus visualizing how I sectionize with this method my own blog posts. Try to scroll up and down the post and see how the table of content updates depending on which section is in view!

Wiring up everything

Now it was time to wire everything up. The last thing I needed was to get the ids of the sections and pass them to the Hook. There were multiple ways I could have proceeded:

  • ArrowAn icon representing an arrow
    Doing it at build time: use some regex magic to get all those ids and pass them in the frontmatter of my markdown, complex but reliable.
  • ArrowAn icon representing an arrow
    Doing it at render time: on mount query all <section/> elements in the document, get the ids and set them in a local state, easy but hacky.

For now I chose the second option:

Example of usage of useScrollspy in an Markdown/MDX based blog post layout

1
const BlogPost = () => {
2
const [ids, setIds] = React.useState<Array<{ id: string; title: string }>>(
3
[]
4
);
5
6
React.useEffect(() => {
7
const titles = document.querySelectorAll('h2');
8
const idArrays = Array.prototype.slice
9
.call(titles)
10
.map((title) => ({ id: title.id, title: title.innerText })) as Array<{
11
id: string;
12
title: string;
13
}>;
14
setIds(idArrays);
15
}, [slug]);
16
17
/**
18
* Get the index of the current active section that needs
19
* to have its corresponding title highlighted in the
20
* table of content
21
*/
22
const [currentActiveIndex] = useScrollspy(
23
ids.map(
24
(item) => document.querySelector(`section[id="${item.id}-section"]`)!
25
),
26
{ offset: YOUROFFSET }
27
);
28
29
// Render blog post and table of content
30
};

I hope this blog post brought some light on what a Scrollspy is, Intersection Observers, and also how I implemented my table of content components that so many of you seem to like so much (thank you all for the overall compliments on my blog by the way, I really appreciate them 😄).

If you have any questions, suggestions, or if something in my codebase isn't clear enough, do not hesitate to reach out! I'm always looking for ways to improve the way I write React components and set a good example for other frontend developers.

Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

– Maxime

A deep dive into how the inner workings of a Scrollspy, Intersection Observers, and how to integrate it with Markdown-based static sites without the need of third party libraries.