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>
@jbundziow
Copy link

Thanks, it works :)

Tip

You can also add this code at the end of the <script> for smooth scrolling to the section:

  document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
    anchor.addEventListener("click", function (e) {
      e.preventDefault();

      document.querySelector(this.getAttribute("href")).scrollIntoView({
        behavior: "smooth",
      });
    });
  });

@maciejpedzich
Copy link
Author

Thanks, it works :)

Tip

You can also add this code at the end of the <script> for smooth scrolling to the section:

  document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
    anchor.addEventListener("click", function (e) {
      e.preventDefault();

      document.querySelector(this.getAttribute("href")).scrollIntoView({
        behavior: "smooth",
      });
    });
  });

Glad you like it, and thanks for the smooth scroll snippet!

@cworld1
Copy link

cworld1 commented May 9, 2024

I update the code as:

---
import type { MarkdownHeading } from 'astro'
import { generateToc } from '@/utils'

interface Props {
  headings: MarkdownHeading[]
}

const { headings } = Astro.props

const toc = generateToc(headings)
---

<aside class='sticky top-20 order-2 -me-28 hidden basis-60 lg:flex lg:flex-col'>
  <toc-heading>
    <h2 class='font-semibold'>TABLE OF CONTENTS</h2>
    <ul class='text-card-foreground'>
      {toc.map((heading) => <li><a href='...' class='aria-selected:font-medium aria-selected:text-primary'>...</a></li> )}
    </ul>
  </toc-heading>
</aside>

<script>
  class TOC extends HTMLElement {
    headings!: HTMLElement[]
    tocLinks!: HTMLAnchorElement[]
    activeLink!: HTMLAnchorElement | undefined

    constructor() {
      super()

      // initialize the headings and tocLinks
      this.headings = Array.from(
        document.querySelectorAll('article > #content > h2, article > #content > h3')
      )
      this.tocLinks = Array.from(this.querySelectorAll('a[href^="#"]'))
      this.activeLink = undefined
    }

    updateActiveTOCItem = (entries: IntersectionObserverEntry[]) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue

        // old link should be inactive
        if (this.activeLink !== undefined) this.activeLink.ariaSelected = 'false'

        // get new link and replace it
        const newActiveLink = this.tocLinks.find(
          (link) => `#${entry.target.id}` === link.getAttribute('href')
        )
        if (newActiveLink) newActiveLink.ariaSelected = 'true'
        this.activeLink = newActiveLink
      }
    }
    connectedCallback() {
      // set observer
      this.headings.forEach((heading) =>
        new IntersectionObserver(this.updateActiveTOCItem, {
          root: null,
          rootMargin: '0% 0% -50% 0%',
          threshold: [1]
        }).observe(heading)
      )

      // smooth scroll
      const self = this
      this.tocLinks.forEach((anchor) => {
        anchor.addEventListener('click', function (e) {
          e.preventDefault()
          const directHeading = self.headings.find(
            (heading) => `#${heading.id}` === this.getAttribute('href')
          )
          directHeading?.scrollIntoView({
            behavior: 'smooth'
          })
        })
      })
    }
  }

  customElements.define('toc-heading', TOC)
</script>

It might improve performance in repeatedly obtaining items. And make it into a web component can help to make code structures better. More ts & es features is support.

The full example is 1 , 2 and 3.

@Delmotte-Vincent
Copy link

Delmotte-Vincent commented Aug 24, 2024

Thanks, it works :)

Tip

You can also add this code at the end of the <script> for smooth scrolling to the section:

  document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
    anchor.addEventListener("click", function (e) {
      e.preventDefault();

      document.querySelector(this.getAttribute("href")).scrollIntoView({
        behavior: "smooth",
      });
    });
  });

It's a cool solution but when you click it doesn't add the hash at the end of the URL.

You can add this line to fix it history.pushState(null, null, this.getAttribute("href"));

The final code looks like that :

document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
    anchor.addEventListener("click", function (e) {
        e.preventDefault();

        history.pushState(null, null, this.getAttribute("href"));
 
        document.querySelector(this.getAttribute("href")).scrollIntoView({
            behavior: "smooth",
        });
    });
});

@cworld1
Copy link

cworld1 commented Aug 27, 2024

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

@cworld1
Copy link

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

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