Skip to content

Instantly share code, notes, and snippets.

@maciejpedzich
Last active May 8, 2026 15:55
Show Gist options
  • Select an option

  • Save maciejpedzich/000da5c6b3a91290d49a91c9fe940ca3 to your computer and use it in GitHub Desktop.

Select an option

Save maciejpedzich/000da5c6b3a91290d49a91c9fe940ca3 to your computer and use it in GitHub Desktop.
Astro Table Of Contents Component + Sample Usage
---
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
// @ - alias for "src" directory
import TableOfContents from '@/components/TableOfContents.astro';
export async function getStaticPaths() {
const posts = await getCollection('YOUR_COLLECTION_NAME_HERE');
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<'YOUR_COLLECTION_NAME_HERE'>;
const post = Astro.props;
const { Content, headings } = await render(post);
---
<h1>{post.data.title}</h1>
<TableOfContents headings={headings} />
<article>
<Content />
</article>
---
import type { MarkdownHeading } from 'astro';
type Props = {
headings: MarkdownHeading[];
};
type HeadingWithSubheadings = MarkdownHeading & {
subheadings: MarkdownHeading[];
};
const { headings } = Astro.props;
const grouppedHeadings = headings.reduce((array, heading) => {
if (heading.depth === 2) {
array.push({ ...heading, subheadings: [] });
} else if (heading.depth === 3) {
array.at(-1)?.subheadings.push(heading);
}
return array;
}, [] as HeadingWithSubheadings[]);
---
<nav id="table-of-contents" aria-label="Table Of Contents">
<ol>
{
grouppedHeadings.map((h) => (
<li>
<a href={`#${h.slug}`}>{h.text}</a>
{h.subheadings.length > 0 && (
<ol>
{h.subheadings.map((sub) => (
<li>
<a href={`#${sub.slug}`}>{sub.text}</a>
</li>
))}
</ol>
)}
</li>
))
}
</ol>
</nav>
<script is:inline>
// This script tag is useful only if you want to display the TOC alongside the blog post...
// ... and highlight the section that the user is currently reading through.
// Feel free to remove this tag if you don't need this type of functionality.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const headingFragment = `#${entry.target.id}`;
const tocItem = document.querySelector(`a[href="${headingFragment}"]`);
if (entry.isIntersecting) {
const previouslyActivatedItem =
document.querySelector('.active-toc-item');
previouslyActivatedItem?.classList.remove('active-toc-item');
tocItem.classList.add('active-toc-item');
} else {
const isAnyOtherEntryIntersecting = entries.some(
(e) => e.target.id !== entry.target.id && e.isIntersecting
);
if (isAnyOtherEntryIntersecting) {
tocItem.classList.remove('active-toc-item');
}
}
}
},
{ root: null, rootMargin: '0px', threshold: [1] }
);
const sectionHeadings = document.querySelectorAll(
'article > h2, article > h3'
);
for (const heading of sectionHeadings) {
observer.observe(heading);
}
</script>
<style>
.active-toc-item {
font-weight: bold;
}
</style>
@variaSmol
Copy link
Copy Markdown

Thank you so much 😭😭😭

@maciejpedzich
Copy link
Copy Markdown
Author

Thank you so much 😭😭😭

You're welcome! Glad this gist is still helpful all these years later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment