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>
@cworld1
Copy link
Copy Markdown

cworld1 commented Aug 27, 2024

@Delmotte-Vincent thanks for your reminds! It looks greater

@cworld1
Copy link
Copy Markdown

cworld1 commented Aug 27, 2024

Emmm... null is not allowed in the latest version of Astro. better code like:

connectedCallback() {
  // Smooth scroll
  this.tocLinks.forEach((link) => {
    link.element.addEventListener('click', (e) => {
      e.preventDefault()
      // Push the history to add the hash at the end of the URL
      const directHeading = this.headings.find((heading) => heading.id === link.slug)
      if (directHeading) {
        // Push the history to add the hash at the end of the URL
        history.pushState(null, directHeading.textContent || "", this.getAttribute("href"));
        directHeading.scrollIntoView({ behavior: 'smooth' });
      } else {
        console.warn(`No heading found for slug: ${link.slug}`);
      }
    })
  })

  // Initial first and listen to scroll event
  setInterval(this.updatePositionAndStyle, 100)
  window.addEventListener('scroll', this.updatePositionAndStyle)
}

which use textContent for param title.

@susansilver
Copy link
Copy Markdown

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
Copy Markdown
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.

@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