How to Make a Table of Contents Component in Astro

Web Reaper
6 min read

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
- Install with:
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:
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:
headings: An array of heading objects that Astro automatically extracts from your markdown contentlevels: 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:
---const { headings, levels = 2 } = Astro.props as Props;
// Filter headings to only show up to the specified levelconst 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:
- Takes our props and sets a default of 2 for
levels - Filters the headings to only show up to the specified level
- Maps over the filtered headings to create links
- Uses different padding levels to create a visual hierarchy
- 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:
<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:
- Finds all heading tags in your blog post content
- Creates an Intersection Observer to watch when headings enter/exit the viewport
- Updates the active link in the table of contents when a heading becomes visible
- Adds scroll margin to headings so they don’t get hidden under fixed headers
- Reinitializes when using Astro’s View Transitions
Production-ready Astro 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:
---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:
- Imports the TableOfContents component
- Gets the headings from your markdown content (Astro provides this automatically)
- Makes the table of contents sticky so it stays visible while scrolling
- 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!
---import { getCollection } from "astro:content";
// layoutimport 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:
- Use
position: stickyon a wrapper to keep it visible while scrolling - Add enough padding and margin to make the hierarchy clear
- Use subtle borders and background colors to separate it from the main content
- Add hover and active states for better interactivity
- Make sure the text is readable but not competing with your main content
- 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. 🚀
