I always loved that section I find in some blogs / articles that tells me what I expect to find, even better if it enables me to go to a specific section that I find interesting. Saves time, shows respect. So I said to myself why not add this feature to my blog? It’s the first time I’m implementing something like this. So I decided to share my experience with you.
So, at first, I’m writing the articles in .md files. I skimmed through my first article and I noticed I make my headings in ## level (2). So, the first thing is to make a function that extract these headings from an .md file.
Extracting Headings from Markdown
Since I’m using Astro and my articles are written in markdown, I needed to extract the headings during build time. Here’s the function I created:
function extractTOC(mdContent: string) {
const headingRegex = /^##\s+(.+)$/gm;
const headings: Array<{ text: string; id: string; level: number }> = [];
let match;
while ((match = headingRegex.exec(mdContent)) !== null) {
const text = match[1].trim();
const id = text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
headings.push({ text, id, level: 2 });
}
return headings;
}
This function does a few important things:
- Uses regex
/^##\s+(.+)$/gm
to match level 2 headings (##) - Extracts the heading text and creates a clean ID for URL navigation
- Returns an array of heading objects with text, id, and level
The ID generation process converts something like ## What's New in React?
into whats-new-in-react
by removing special characters and replacing spaces with hyphens.
Building the TOC Structure
Once I had the headings extracted, I needed to display them in my blog layout. I added this to my Astro component:
{
tocHeadings.length > 0 && (
<div class="hidden lg:block lg:w-64 lg:flex-shrink-0">
<nav
class="sticky top-4 right-0"
aria-label="Table of Contents"
id="toc-nav"
>
<div class="lg:border-gray-700 lg:border-l-1 pl-4">
<h3 class="text-xl font-semibold mb-4">
Table of Contents
</h3>
<ul class="space-y-2 text-sm">
{tocHeadings.map((heading) => (
<li>
<a
href={`#${heading.id}`}
class="toc-link"
data-target={heading.id}
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</nav>
</div>
)
}
Adding some CSS
Now, Let’s make it presentable and nicer.
.toc-link {
border-left: 2px solid transparent;
padding-left: 0.5rem;
margin-left: -0.5rem;
display: block;
transition: color 0.3s, border-color 0.3s;
}
.toc-link:hover {
color: var(--color-accent-blue);
}
.toc-link.active {
color: var(--color-accent-blue);
border-left-color: var(--color-accent-blue);
font-weight: 600;
}
Let’s make it interactive
Now, I wanted it to be more interactive. To dynamically show the currently active heading in the TOC. This was the trickiest part. I needed to track which heading is currently visible and update the TOC accordingly. I thought about a few different solutions, but eventually settled on Intersection Observer.
document.addEventListener("DOMContentLoaded", () => {
// Select all TOC links and headings with IDs
const links = Array.from(
document.querySelectorAll(".toc-link"),
) as HTMLElement[];
const headings = Array.from(
document.querySelectorAll("h2[id]"),
) as HTMLElement[];
// If there are no links or headings, just return
if (!links.length || !headings.length) return;
const activate = (id: string) => {
links.forEach((link) => {
// Toggle 'active' class based on whether this link matches the current heading
link.classList.toggle("active", link.dataset.target === id);
});
};
// Create an Intersection Observer
const observer = new IntersectionObserver(
(entries) => {
// Find the first heading that's currently intersecting (visible)
const entry = entries.find((e) => e.isIntersecting);
// If a heading is visible, activate its corresponding TOC link
if (entry) activate(entry.target.id);
},
{
// Add some more lenience to the Intersection Observer
rootMargin: "-20% 0px -60% 0px",
// Threshold 0 means trigger as soon as any part of the heading is visible
// within the adjusted viewport (defined by rootMargin)
threshold: 0,
},
);
// Start observing all headings for intersection changes
headings.forEach((heading) => observer.observe(heading));
});
The Intersection Observer watches all the headings and updates the active state when they come into view. The rootMargin
settings ensure a heading is only considered “active” when it’s prominently visible, not just barely in the viewport.
Handling Clicks and URL Updates
I also wanted the TOC links to update the browser URL when clicked:
links.forEach((link) => {
link.addEventListener("click", (e) => {
const id = link.dataset.target;
if (id) {
activate(id);
history.pushState(null, "", `#${id}`);
}
});
});
// Handle initial hash and hash changes
const initial = location.hash.slice(1);
if (initial) activate(initial);
else activate(headings[0].id);
window.addEventListener("hashchange", () => {
activate(location.hash.slice(1));
});
This ensures that when someone clicks a TOC link, the URL updates with the heading anchor, and if they share that URL, it will scroll to the right section.
What I Learned
Building this TOC taught me several things:
Performance matters: Using Intersection Observer instead of scroll events makes the page much more responsive.
Progressive enhancement: The TOC works even if JavaScript fails to load, thanks to the anchor links.
hashchange
really?: Before implementing this, I didn’t even know about the hashchange
event, which is a nice feature to have.
The Result
Now my blog posts have a clean, functional TOC that:
- Shows reading progress
- Enables quick navigation
- Works on all devices (hidden on mobile to save space)
- Updates the URL for easy sharing
It’s one of those features that seems simple but involves quite a bit of thought about user experience, performance, and accessibility. I’m really happy with how it turned out, please let me know if you have any feedback or suggestions!