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 intersectingif (entry.isIntersecting) {// remove active class from all linksdocument.querySelectorAll("#toc a").forEach((e) => e.classList.remove("active"));// add active class to the currently active entrytocLinkEl.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 observeconst elToObserve = document.querySelectorAll("article :is(h2,h3)")// finally, observe the elementselToObserve.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.