Add TOC with Scroll Spy in Astro
Fazza Razaq Amiarso
Posted on January 8, 2023
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.
Posted on January 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.