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

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

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

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
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