How to Build a Table of Contents for Your Astro Blog

I recently migrated this site from WordPress to Astro using Claude Code. Most of the port went smoothly, but one thing I couldn’t find a good solution for was a table of contents.

The Astro TOC tutorials I found were either too basic (just a flat list of links) or way too complex for what I needed. I wanted something that:

  • Groups h3s under their parent h2
  • Sticks to the sidebar on desktop
  • Highlights the current section as you scroll
  • Collapses into a neat box on mobile
  • Can be turned on or off with a single line change

So I built my own. It’s currently hidden on this site while I figure out the layout, but the code works and here’s the full implementation.

Table of Contents

The heading hierarchy

Astro gives you a flat array of headings from your rendered markdown. The first step is turning that flat list into a two-level tree where h3s nest under h2s.

Here’s the TypeScript interface and the builder function:

// TOCHeading.astro (frontmatter)
import type { MarkdownHeading } from 'astro';

export interface HeadingHierarchy extends MarkdownHeading {
  subheadings: HeadingHierarchy[];
}
// ArticleLayout.astro (frontmatter)
function createHeadingHierarchy(items: MarkdownHeading[]): HeadingHierarchy[] {
  const top: HeadingHierarchy[] = [];
  for (const heading of items) {
    if (heading.depth < 2 || heading.depth > 3) continue;
    const node: HeadingHierarchy = { ...heading, subheadings: [] };
    if (heading.depth === 2 || top.length === 0) {
      top.push(node);
    } else {
      top[top.length - 1].subheadings.push(node);
    }
  }
  return top;
}

This skips h1 (since that’s your post title) and anything deeper than h3 to keep the TOC compact. Each h2 becomes a top-level item, and h3s get tucked underneath.

The TOC component

The actual table of contents is a <details> element so it can be collapsed. On desktop the sidebar version stays open, on mobile the in-content version starts collapsed.

---
// TableOfContents.astro
import TOCHeading from './TOCHeading.astro';
import type { HeadingHierarchy } from './TOCHeading.astro';

const { toc } = Astro.props as { toc: HeadingHierarchy[] };
---
<details class="toc" open>
  <summary class="toc__title">Table of Contents</summary>
  <nav class="toc__nav" aria-label="Table of contents">
    <ul class="toc__list">
      {toc.map((heading) => <TOCHeading heading={heading} />)}
    </ul>
  </nav>
</details>

Each heading item is rendered recursively:

---
// TOCHeading.astro
import type { MarkdownHeading } from 'astro';

export interface HeadingHierarchy extends MarkdownHeading {
  subheadings: HeadingHierarchy[];
}

const { heading } = Astro.props as { heading: HeadingHierarchy };
---
<li class="toc__item">
  <a class="toc__link" href={`#${heading.slug}`}>{heading.text}</a>
  {heading.subheadings.length > 0 && (
    <ul class="toc__sublist">
      {heading.subheadings.map((sub) => <Astro.self heading={sub} />)}
    </ul>
  )}
</li>

The <Astro.self> call makes the component recursive - it renders its own children using itself. This handles any nesting depth, though we’re only feeding it two levels.

Scrollspy

The scrollspy script lives at the bottom of TableOfContents.astro. It uses IntersectionObserver to watch each heading element and highlights the corresponding TOC link when it enters the viewport:

<script>
  const links = Array.from(
    document.querySelectorAll<HTMLAnchorElement>('.toc__link')
  );
  if (links.length) {
    const byId = new Map<string, HTMLAnchorElement>();
    for (const link of links) {
      const id = decodeURIComponent(link.hash.slice(1));
      if (id) byId.set(id, link);
    }
    const targets = [...byId.keys()]
      .map((id) => document.getElementById(id))
      .filter((el): el is HTMLElement => el !== null);

    let activeId = '';
    const setActive = (id: string) => {
      if (id === activeId) return;
      activeId = id;
      for (const link of links) link.classList.remove('is-active');
      byId.get(id)?.classList.add('is-active');
    };

    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
        if (visible[0]) setActive(visible[0].target.id);
      },
      { rootMargin: '-80px 0px -70% 0px', threshold: 0 }
    );
    for (const t of targets) observer.observe(t);
  }
</script>

The rootMargin is tuned so the “active” heading switches when it’s near the top of the viewport (accounting for a sticky header). The -70% 0px bottom margin means a heading is only considered “in view” when it’s in the top 30% of the screen.

The CSS

Here’s the styling. The sidebar version sticks below the header, and the active link gets a blue left border:

.post-toc {
  position: sticky;
  top: 5rem;
  align-self: start;
}

.toc {
  font-size: 0.9rem;
  line-height: 1.45;
  max-height: calc(100vh - 7rem);
  overflow-y: auto;
}

.toc__title {
  cursor: pointer;
  list-style: none;
  font-size: 0.72rem;
  font-weight: 650;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
  padding: 0 0 0.7rem;
  margin-bottom: 0.4rem;
  border-bottom: 1px solid var(--color-border);
}

.toc__list,
.toc__sublist {
  list-style: none;
  margin: 0;
  padding: 0;
}

.toc__sublist {
  padding-left: 0.9rem;
}

.toc__link {
  display: block;
  padding: 0.2rem 0;
  color: var(--color-text-muted);
  text-decoration: none;
  border-left: 2px solid transparent;
  padding-left: 0.7rem;
  margin-left: -0.7rem;
  transition: color 0.12s ease, border-color 0.12s ease;
}

.toc__link:hover {
  color: var(--color-link);
}

.toc__link.is-active {
  color: var(--color-heading);
  border-left-color: var(--color-accent);
  font-weight: 600;
}

For mobile, the sidebar disappears and a collapsible in-content version shows up instead:

@media (max-width: 900px) {
  .post-toc { display: none; }

  .toc--mobile {
    display: block;
    border: 1px solid var(--color-border);
    border-radius: 8px;
    background: var(--color-surface);
    padding: 0.4rem 1.1rem 0.8rem;
    margin: 1.5rem 0 2rem;
  }

  .toc--mobile .toc__title {
    padding-top: 0.7rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .toc--mobile .toc__title::after {
    content: '+';
  }

  .toc--mobile[open] .toc__title::after {
    content: '-';
  }
}

Wiring it up in the layout

In your article layout, you need a two-column grid - content on the left, TOC sidebar on the right:

.post-layout--toc {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 15rem;
  column-gap: clamp(2rem, 5vw, 4rem);
  align-items: start;
}

And in the Astro template, a simple toggle controls whether the TOC shows up:

const hasToC = tocCount >= 3;
<div class:list={['post-layout', { 'post-layout--toc': hasToC }]}>
  {hasToC && (
    <aside class="post-toc">
      <TableOfContents toc={toc} />
    </aside>
  )}
  <article class="post-content">
    <slot />
  </article>
</div>

The tocCount >= 3 check means the TOC only appears on posts with at least three headings. No point showing a table of contents for a post with two sections.

Turning it on and off

Want to disable the TOC globally? Change that one line:

const hasToC = false; // was: tocCount >= 3

Want it back? Flip it to tocCount >= 3. That’s it. All the CSS, components, and scrollspy code stays in place - the toggle just controls whether the sidebar renders.

This approach has worked well for me. It’s self-contained, doesn’t need any external packages, and the scrollspy feels smooth. If you’re building an Astro blog and want a TOC that actually looks and works like it belongs there, feel free to grab this code and adapt it.