logo
Our pricing insights are free and supported until July 2, 2025. Learn more about our decision to sunset PriceLevel →
Technical

Adding Scrollspy in Astro

Steven Rapp
Jul 23, 2024
Hero image of Adding Scrollspy in Astro

I’ve written previously about how great Astro is for content driven sites, and its common to include a table of contents to help users navigate through longer documents. Thankfully Astro makes that pretty easy to do.

Another common design pattern is to have a visual indicator track which section of the document you’re currently viewing. If you’re using Astro’s documentation theme, Starlight, then you’ll already have access to this functionality through their default layouts. But if you’re interested in building this yourself, then you’ll have to get your hands dirty. Hopefully my experience will help to get you on the right track.

Nothing here is strictly tied to Astro - it’s plain HTML and Javascript for the most part, so even if you aren’t using Astro, this should still be useful.  Let’s dive in!

Mission Brief: Markdown and Table of Contents

Markdown and MDX are first-class citizens in Astro’s ecosystem. It’s trivial to take your content from a .md file and render it as HTML. If you drop your file in the pages/ directory, that’s exactly what it’ll do. Alternatively, you can also leverage Astro’s content collections to manage your Markdown content and have fine-grained control over when and how to render it.

In both cases, Astro will provide a headings array that we can use to construct the table of contents for our page. It comes out flat by default, but each object has a depth attribute that you could use to replicate the nested structure if you want to. We’ll keep it simple in this example:

// src/components/TableOfContents.astro
---
const { headings } = Astro.props;
// [{slug: string, text: string, depth: number}]
---
  <div id="toc">
    <div>On this page:</div>
    {headings.map(heading => (
      <li>
        <a href={`#${heading.slug}`}>{heading.text}</a>
      </li>
    ))}
  </div>

Astro renders markdown headers as <h1> - <h6> HTML tags, setting the id of each tag to a slug based on the title. Each heading object includes the slug that corresponds to those ids, and we can use those to generate hash links to each header. We can add some CSS to stick the table of contents to a specific spot on the side of the page layout, and we have our static version complete!

// src/layouts/BlogLayout.astro
---
import Layout from './Layout.astro'
import TableOfContents from '../components/TableOfContents.astro';
const { headings, frontmatter: { title } } = Astro.props;
---
<Layout>
  <div class="container">
    <nav>
      <TableOfContents headings={headings}/>
    </nav>
    <main>
      <h1>{title}</h1>
      <slot/>
    </main>
  </div>
</Layout>

<style>
nav {
  position: sticky;
  top: 6rem;
}
</style>

An example blog post with a static table of contents pinned to the left side. We asked Milka for help writing this demo post and she might have got a little carried away.
An example blog post with a static table of contents pinned to the left side. We asked Milka for help writing this demo post and she might have got a little carried away.

The Real Mission Begins

Now for the interesting part: we’ll want to track when a user scrolls and see if they’re viewing a new section. To do this, we’ll leverage an IntersectionObserver, which fires an event whenever the elements we tell it to observe intersect with the viewport of the browser. It requires a callback function that accepts a set of entries consisting of the elements that triggered the intersection. 

Each entry includes the target element and an intersectionRatio representing the percentage of the target that is intersecting - or in our case, how much of the target is visible. When an object becomes visible, the IntersectionObserver will fire with a non-zero intersectionRatio, and when the object leaves, it fires again with the intersectionRatio set to 0. We can use this behavior to make a simple version of scroll spying.

// src/components/TableOfContents.astro
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.intersectionRatio > 0) {
        document.querySelector(`#toc a[href='#${entry.target.id}']`)
          .classList.add("active");
      } else {
        document.querySelector(`#toc a[href='#${entry.target.id}']`)
          .classList.remove("active");
      }
    });
  }
);

const headers = document.querySelectorAll("h2,h3,h4,h5,h6");
headers.forEach((section) => {
  observer.observe(section);
});

We’ve set up our IntersectionObserver to look at each header and modify the anchor tag in the table of contents that links to that header. If the intersectionRatio is non-zero, then the header is visible on the page and marked with the class active. Otherwise, the active class is removed.

This simple implementation works well, so long as you're okay with multiple sections being highlighted simultaneously.
This simple implementation works well, so long as you're okay with multiple sections being highlighted simultaneously.

This works and is a common solution, but it has two drawbacks that I wasn’t a fan of:

  • It will highlight multiple entries in the table of contents if multiple headers are visible
  • It will remove the indicator for an entry once the header is off the page, even if all the content that header represents was still visible

We’ll need to iterate beyond this straightforward solution if we want to address these issues.

Tracking Sections, Not Headers

Let’s address the latter issue first: what we need to track isn’t whether the headers were visible, but rather whether the content they represented were visible. We need some additional HTML structure to target. The simplest approach would be to wrap the header and its associated content in <section> tags, and thankfully there’s a plugin for that!

Astro uses remark, rehype, and other markdown libraries under the hood, enabling us to leverage their ecosystem. We can install remark-sectionize and include it in our Astro configuration to automatically wrap our content in <section> tags, grouping it with the appropriate header. Now that we have these bounding boxes, we can reconfigure our IntersectionObserver to look at these instead:

// src/components/TableOfContents.astro
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      const id = entry.target.querySelector('h2,h3,h4,h5,h6').id

      if (entry.intersectionRatio > 0) {
        document.querySelector(`#toc a[href='#${id}']`)
          .classList.add("active");
      } else {
        document.querySelector(`#toc a[href='#${id}']`)
          .classList.remove("active");
      }
    });
  }
);

const sections = document.querySelectorAll("section");
sections.forEach((section) => {
  observer.observe(section);
});

Well that’s an improvement, sections are no longer marked inactive as soon as the user scrolls past the header. We still mark multiple sections as active, and if that’s sufficient for your use case, you can stop here. Otherwise, let’s keep going.

There Can Only Be One

If we only want to mark one section as active, we’ll need to figure out how to choose which visible section is the winner. The approach I landed on was to select the section that was most visible, or to put in terms of code, the entry with the highest intersectionRatio. In the case of a tie, we’ll pick the one furthest up the page.

// src/components/TableOfContents.astro
const sectionVisibility = {}

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      const id = entry.target.querySelector('h2,h3,h4,h5,h6').id
      sectionVisibility[id] = entry.intersectionRatio
    });

    const maxVisibility = Math.max(...Object.values(sectionVisibility));
    const [activeId, intersectionRatio] = Object.entries(sectionVisibility)
      .find(([id, visibility]) => visibility == maxVisibility)

    document.querySelectorAll('#toc a')
      .forEach(a => a.classList.remove("active"));
    document.querySelector(`#toc a[href='#${activeId}']`)
      .classList.add("active");
  }
);

We’re technically relying on Javascript’s implementation of Objects and how it sorts property keys for Object.entries(), which will work well enough in practice. But we have a problem: the indicator jumps around, sometimes skipping certain sections or staying too long on other sections:

Notice how the menu indicator skips over "Soft and Sensational Snacks" and completely misses "Homemade Happiness" despite clearly taking the majority of the viewport.
Notice how the menu indicator skips over "Soft and Sensational Snacks" and completely misses "Homemade Happiness" despite clearly taking the majority of the viewport.

The problem is with the IntersectionObserver’s thresholds. By default, an IntersectionObserver’s threshold is set to 0, meaning the callback will fire as soon as any portion of the target element becomes visible, and won’t fire again until the element is no longer visible. This means that once a user scrolls to a section, its intersectionRatio is fixed to the initial value when it was displayed on the screen and doesn’t update as the user scrolls further.

We can adjust the thresholds of the IntersectionObserver to update at certain percentages. For instance, you could set the threshold to 0.5, meaning the callback won’t fire until at least 50% of the target is visible. You can also set multiple percentages and the IntersectionObserver will fire when a target exceeds each threshold, which will be the key for fixing the erratic behavior of our table of contents.

There’s a lot of tweaking you can do, but I’ve general found success starting with thresholds [0.33, 0.66, 1], meaning the IntersectionObserver’s callback will execute for each section whenever they cross 33%, 66%, and 100% visibility.

// src/components/TableOfContents.astro
const observer = new IntersectionObserver(
  (entries) => {
    // Callback code from above
  },
  { threshold: [0.33, 0.66, 1] }
);

Mission Accomplished

Our scroll-spying table of contents works pretty well! If you’re looking to implement this yourself or want to play further with the solution described here, check out the StackBlitz environment for this project.

The final result with silky smooth transitions between sections.
The final result with silky smooth transitions between sections.

If you want to see this in action on PriceLevel, take a look at our updated content for one of the most broadly applicable categories of software: Applicant Tracking Systems. We plan to roll this out to more categories and vendor pages, but we need your help. Your mission, should you choose to accept it, is as follows:

Get transparent prices
Discover what companies actually pay for software. PriceLevel gives you visibility into the price hidden behind "Contact Us".