Back to Blog List

How I implemented a dynamic Table Of Content section in my personal blog

Published | 5 min read

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!