Last active
March 17, 2025 14:14
-
-
Save maciejpedzich/000da5c6b3a91290d49a91c9fe940ca3 to your computer and use it in GitHub Desktop.
Astro Table Of Contents Component + Sample Usage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
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> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for the heads up! It's been a while since I've last updated this gist, so I'll try and do it tomorrow.
Edit: I've just updated the gist. Thanks once again for pointing out the breaking change.