Add TOC with Scroll Spy in Astro
Add Table of Content to an Astro blog with Scroll Spying in Vanilla JS
Sometimes, there is a table of content to help us navigate through a blog more easily. When building a custom blog with remark and rehype, a standard solution is to use the rehype-toc
plugin.
In Astro, we can build a table of content without extra plugins. Astro already provides us with the necessary property by default. As an extra touch, we will add a scroll spy to keep track of our current heading.
I use the Astro blog starter template as a starting point.
Create the Component
Let's create a new Astro component called TOC.astro
and define the needed props.
---
export type Props = {
pageHeadings : Array<{depth:number,text: string; slug: string }>
}
const { pageHeadings } = Astro.props;
---
<aside id="#toc">
<ul>{pageHeadings.map(h => {
return <li> <a href={`#${h.slug}`}>{h.text}</a></li>
})}</ul>
</aside>
The pageHeadings
props will be special headings
props passed from the astro layout component. Astro automatically assigns an id to all headings in markdown, which become slug in headings
props.
[{
text : "Implement TOC",
depth : 1,
slug: "implement-toc"
},
{
text : "Create Markup",
depth : 2,
slug: "create-markup"
}]
Insert the TOC into the blog layout and pass it to the special headings
props
---
const {headings} = Astro.props;
---
<body>
<Header />
<main>
<article>
<h1>{content.title}<h1>
<hr />
<slot />
<TOC pageHeadings={headings} />
</article>
</main>
<Footer />
</body>
Let's add a little style to the TOC, so it's fixed to the right.
<style>
#toc {
position: fixed;
top: 0;
right: 5rem;
}
ul {
list-style: none;
}
a {
text-decoration: none;
}
</style>
Here's the result.
The TOC is working and sufficient for most use cases. But, we will enhance it with a scroll spy to highlight the active heading.
Scroll Spy with Intersection Observer
We must let the TOC know which heading is intersecting by observing/spying on the heading with Intersection Observer.
Add an Observer Callback Function
Firstly, insert a script tag in the TOC file. Afterward, create the observer callback function responsible for detecting and setting the active state.
<script>
const setCurrentHeading : IntersectionObserverCallback = (entries) => {
// loop to each entries (headings) in the page
for (let entry of entries) {
// equivalent to the slug returned from pageHeadings
const { id } = entry.target;
// get the TOC link's element for the current entry
const tocLinkEl = document.querySelector(`#toc a[href='#${id}']`);
if(!tocLinkEl) return;
}
}
</script>
The above code loops through each entry and selects the link element. It also has a guard in case the link element doesn't exist, which is unlikely.
Then, add the active styling to the intersecting entry.
// check if the entry is intersecting
if (entry.isIntersecting) {
// remove active class from all links
document.querySelectorAll("#toc a").forEach((e) => e.classList.remove("active"));
// add active class to the currently active entry
tocLinkEl.classList.add("active");
}
Here's the active class for this example.
a.active {
color: red;
font-weight: 600;
}
Observer Option
Let's define the option for the observer.
const observerOptions = {
threshold: 1,
rootMargin : "0px 0px -66%"
}
Here's the explanation for the option.
threshold: 1
means we want to register the element as an entry when the element is fully visible.rootMargin: "0px 0px -66%"
means we crop the observer's viewport height by 66% at the bottom. So, our viewport have 33% of it's height. It's helpful because we want the entry to be active only when a user has scrolled enough past the heading.
Observe the Headings
We have all the pieces needed to create an observer instance to observe the headings.
const observer = new IntersectionObserver(setCurrentHeading, observerOptions);
// select all headings to observe
const elToObserve = document.querySelectorAll("article :is(h2,h3)")
// finally, observe the elements
elToObserve.forEach(el => observer.observe(el))
What the code does is select all headings that we want to observe. Then, loop through each heading and observe them by calling observe()
.
Here's the final result.
You can also see how I implement it in my blog with TailwindCSS and sticky
positioning.