How to Make a Table of Contents Component in Astro

Web Reaper avatar

Web Reaper

6 min read

Cover for How to Make a Table of Contents Component in Astro

Tired of making your readers dig through a sea of text to find the one paragraph that actually answers their question? Yeah, me neither. That’s why we’re going to make a table of contents component in Astro. Let’s go. In and out, 20 minutes adventure.

What will the Table of Contents Component do?

A table of contents component provides an overview of your blog post’s structure and allows readers to quickly jump to specific sections. The component we’ll create will:

  • Automatically generate links from your markdown headings
  • Allow configuring how many heading levels to show
  • Highlight the current section as you scroll through the post
  • Be sticky, so it’s always visible while reading

TIP

You can see this component in action on the right side of this post!

Prerequisites

Before we get started, make sure you have:

  • An Astro project, probably using the blog starter project
    • If you don’t know how to do this, follow these instructions and select the blog starter project
  • Tailwind installed in your project
    • Install with: npx astro add tailwind

Creating the Table of Contents Component

Component Interface

First, let’s create the component at src/components/TableOfContents.astro. We’ll start by defining the interface for our headings and component props:

src/components/TableOfContents.astro
interface Heading {
depth: number; // The heading level (h1 = 1, h2 = 2, etc)
slug: string; // The ID of the heading for linking
text: string; // The text content of the heading
}
interface Props {
headings: Heading[]; // Array of headings from your markdown content
levels?: 1 | 2 | 3; // How many levels of headers to show in TOC (default: 2)
}

The component takes two props:

  1. headings: An array of heading objects that Astro automatically extracts from your markdown content
  2. levels: Optional parameter to control how many levels of headings to display (h1, h2, h3)

Component Template

Here’s how we’ll structure the component’s template:

src/components/TableOfContents.astro
---
const { headings, levels = 2 } = Astro.props as Props;
// Filter headings to only show up to the specified level
const filteredHeadings = headings.filter((heading) => heading.depth <= levels);
---
<div class="border-base-400 rounded-xs border p-3 text-sm leading-tight">
<h4 class="text-xl font-medium">Table of Contents</h4>
<ul class="mt-4 flex flex-col gap-2">
{
filteredHeadings.map((heading) => (
<li
class:list={{
"pl-3": heading.depth === 2,
"pl-6": heading.depth === 3,
}}
>
<a
href={`#${heading.slug}`}
class="toc-link transition hover:text-blue-600"
>
{heading.text}
</a>
</li>
))
}
</ul>
</div>

The template:

  1. Takes our props and sets a default of 2 for levels
  2. Filters the headings to only show up to the specified level
  3. Maps over the filtered headings to create links
  4. Uses different padding levels to create a visual hierarchy
  5. Adds hover effects for better interactivity

Adding Active Section Highlighting

To highlight the current section as you scroll, we need to add some JavaScript that uses the Intersection Observer API. Here’s how we’ll do that:

src/components/TableOfContents.astro
<style lang="scss">
.toc-current {
@apply text-blue-600;
}
</style>
<script>
// Wrapper for Blog post content
let wrappingElement: Element | null;
let observeHeaderTags: IntersectionObserver;
let allHeaderTags: NodeListOf<Element>;
// Function that runs when the Intersection Observer fires
function setCurrent(e: IntersectionObserverEntry[]) {
var allSectionLinks = document.querySelectorAll(".toc-link");
e.map((i) => {
if (i.isIntersecting === true) {
allSectionLinks.forEach((link) => link.classList.remove("toc-current"));
const targetLink = document.querySelector(
`a[href="#${i.target.id}"].toc-link`,
);
if (targetLink) targetLink.classList.add("toc-current");
}
});
}
function initTOC() {
// update this with whatever class wraps your blog post content
wrappingElement = document.querySelector(".markdown-content");
if (wrappingElement !== null) {
// Get all H1/H2/H3 tags from the post
allHeaderTags = wrappingElement.querySelectorAll(
":scope > h1, :scope > h2, :scope > h3",
);
}
// Intersection Observer Options
let options: IntersectionObserverInit = {
root: null,
rootMargin: "0px 0px -50% 0px",
threshold: [1],
};
// Each Intersection Observer runs setCurrent
observeHeaderTags = new IntersectionObserver(setCurrent, options);
if (wrappingElement === null) {
return;
}
allHeaderTags.forEach((tag) => {
// add scroll margin top to account for fixed navbar
tag.classList.add("scroll-mt-24");
observeHeaderTags.observe(tag);
});
}
// runs on initial page load
initTOC();
// runs on view transitions navigation
document.addEventListener("astro:after-swap", initTOC);
</script>

TIP

The “-50%” in rootMargin helps make highlighting more natural. While scrolling down, the observer checks when each heading is 50% visible from the top of the viewport.

The JavaScript code:

  1. Finds all heading tags in your blog post content
  2. Creates an Intersection Observer to watch when headings enter/exit the viewport
  3. Updates the active link in the table of contents when a heading becomes visible
  4. Adds scroll margin to headings so they don’t get hidden under fixed headers
  5. Reinitializes when using Astro’s View Transitions

Production-ready Astro Templates

Astro website templates

Templates with tons of features others leave out. I18n, CMS, animations, image optimization, SEO, and more.

Using the Component

To use the TableOfContents component in your blog layout:

src/layouts/BlogLayout.astro
---
import { type CollectionEntry } from "astro:content";
import TableOfContents from "@/components/TableOfContents.astro";
interface Props {
post: CollectionEntry<"blog">;
headings: import("astro").MarkdownHeading[];
}
const { post, headings } = Astro.props as Props;
---
<article class="prose">
<!-- Your blog post content -->
<slot />
</article>
<aside>
<div class="sticky top-20">
<TableOfContents headings={headings} levels={3} />
</div>
</aside>

The layout:

  1. Imports the TableOfContents component
  2. Gets the headings from your markdown content (Astro provides this automatically)
  3. Makes the table of contents sticky so it stays visible while scrolling
  4. Passes the headings and desired number of levels to show

CAUTION

Don’t forget to pass the headings array from your blog post data to the component!

src/pages/blog/[...slug].astro
---
import { getCollection } from "astro:content";
// layout
import BlogLayout from "@/layouts/BlogLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("posts");
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
const post = Astro.props as Props;
const { Content, headings } = await post.render();
---
<BlogLayout post={post} headings={headings}>
<Content />
</BlogLayout>

Styling Considerations

To make the table of contents look and work great, consider these styling tips:

  1. Use position: sticky on a wrapper to keep it visible while scrolling
  2. Add enough padding and margin to make the hierarchy clear
  3. Use subtle borders and background colors to separate it from the main content
  4. Add hover and active states for better interactivity
  5. Make sure the text is readable but not competing with your main content
  6. Consider hiding it on mobile where space is limited

So, what now?

You now have a highly customizable table of contents component that - you can adjust the styling, number of heading levels, and active section highlighting to match your site’s design. Happy coding. 🚀

C O S M I C