Skip to content

Instantly share code, notes, and snippets.

@maciejpedzich
Last active March 17, 2025 14:14
Show Gist options
  • Save maciejpedzich/000da5c6b3a91290d49a91c9fe940ca3 to your computer and use it in GitHub Desktop.
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>
@susansilver
Copy link

susansilver commented Dec 9, 2024

This needs a small update for Astro 5.0 which has changed the way that render works.

For [...slug].astro these are the changes. This is how I have it on my website.

import {
  render,
  CollectionEntry, 
getCollection
} from "astro:content";

export async function getStaticPaths() {
  const posts = await getCollection('YOUR_COLLECTION_NAME_HERE');
  return posts.map((post) => ({
		params: { slug: post.id},
		props: entry,
	}));
}

interface Props {
  entry: CollectionEntry<"YOUR_COLLECTION_NAME_HERE">;
}


const { entry } = Astro.props;
const { Content, headings } = await render(entry);

@maciejpedzich
Copy link
Author

maciejpedzich commented Dec 9, 2024

This needs a small update for Astro 5.0 which has changed the way that render works.

For [...slug].astro these are the changes. This is how I have it on my website.

import {
  render,
  CollectionEntry, 
getCollection
} from "astro:content";

export async function getStaticPaths() {
  const posts = await getCollection('YOUR_COLLECTION_NAME_HERE');
  return posts.map((post) => ({
		params: { slug: post.id},
		props: entry,
	}));
}

interface Props {
  entry: CollectionEntry<"YOUR_COLLECTION_NAME_HERE">;
}


const { entry } = Astro.props;
const { Content, headings } = await render(entry);

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.

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