Overview

Right-hand reading directories give long-form articles instant structure. In this tutorial we will recreate the setup used on my blog: a sticky Table of Contents (TOC) card that highlights the heading you are currently reading. We will capture headings from Markdown content, render them in a dedicated component, and use an IntersectionObserver + scroll fallback to keep the directory in sync with the viewport.

What You Will Build

  • A route layout that renders article content alongside a sticky TOC column
  • A reusable TableOfContents.astro component that groups headings into parent/child sections
  • A client-side script that tracks the reader’s progress and adds the active state to the proper TOC link

Prerequisites: Astro project already set up, Markdown posts using the shared PostLayout, and basic familiarity with TypeScript/JSX syntax inside .astro files.

1. Pass Headings to the Layout

Astro automatically provides heading metadata via the getHeadings() helper. Update your Markdown entry points to pass that data into the layout:

---
import PostLayout from "../layouts/PostLayout.astro";

const { headings } = await Astro.glob("../contents/posts/*.md");
---

<PostLayout headings={Astro.props.headings} ...>
  <slot />
</PostLayout>

The key is that the layout receives a MarkdownHeading[]. If you already have a PostLayout, extend its props:

// src/layouts/PostLayout.astro (frontmatter)
import type { MarkdownHeading } from "astro";

interface Props {
  ...
  headings?: MarkdownHeading[];
}

const { headings = [] } = Astro.props;

2. Expose an Article Root for the Script

Wrap the main article column with a data attribute so the TOC script knows where to look for headings:

<div class="article" data-article-body>
  <Markdown>
    <slot />
  </Markdown>
</div>

This is necessary because Markdown content is rendered inside Markdown.astro rather than a plain <article>. The script will first try [data-article-body] before falling back to article or main.

3. Lay Out the Page

Add a responsive two-column wrapper so the TOC sits on the right for medium+ screens while remaining hidden on mobile:

<div class="content-layout">
  <div class="article" data-article-body>
    ...
  </div>

  {headings.length > 0 && (
    <aside class="toc-col">
      <TableOfContents headings={headings} />
    </aside>
  )}
</div>

<style>
  .content-layout {
    @apply flex flex-col md:flex-row md:gap-8;
  }
  .toc-col {
    @apply hidden md:block md:w-64 md:shrink-0;
    position: sticky;
    top: 6rem;
    max-height: calc(100vh - 6rem);
    overflow-y: auto;
  }
</style>

4. Build the TableOfContents Component

Create src/components/TableOfContents.astro with the following responsibilities:

  1. Group headings by depth so the directory displays primary sections plus nested subsections.
  2. Render a styled list with anchor links (href="#section-id").
  3. Inject a script that observes scroll position and applies .active-toc-item to the correct link.

Group Headings

const parentDepth = Math.min(...headings.map((h) => h.depth));
const childDepth = parentDepth + 1;

const grouped = [];
for (const h of headings) {
  if (h.depth === parentDepth) {
    grouped.push({ ...h, subheadings: [] });
  } else if (h.depth === childDepth) {
    grouped.at(-1)?.subheadings.push(h);
  }
}

const finalToc = grouped.length ? grouped : headings.map((h) => ({ ...h, subheadings: [] }));

Render the Card

Give the card a soft glassmorphism aesthetic, sticky positioning, and explicit hover/active states. Example snippet:

<nav id="table-of-contents" class="toc-card">
  <div class="toc-header">Directory</div>
  <ol class="toc-list">
    {finalToc.map((section) => (
      <li class="toc-section">
        <a class="toc-link" href={`#${section.slug}`}>
          <span class="toc-link-bg"></span>
          <span class="toc-link-inner">
            <span class="toc-link-text">{section.text}</span>
          </span>
        </a>
        ...
      </li>
    ))}
  </ol>
</nav>

Each link contains a .toc-link-bg span for the glowing background and .toc-link-inner for subtle scaling effects. Define .active-toc-item styles to mimic the hover state so the reader sees which section is active.

Add Dark Mode Support

Wrap selectors with .dark #table-of-contents or [data-theme="dark"] to override colors. Keep the palette consistent: dark surfaces, soft shadows, and white text for the active state.

5. Track Reading Progress on the Client

At the bottom of the component, insert an inline script to manage highlighting:

const articleRoot =
  document.querySelector("[data-article-body]") ||
  document.querySelector("article") ||
  document.querySelector("main") ||
  document;

const readingHeadings = Array.from(
  articleRoot.querySelectorAll("h1, h2, h3")
).filter((h) => h.id);

function activateById(id) {
  const link = document.querySelector(`#table-of-contents a[href="#${id}"]`);
  if (!link) return;
  document
    .querySelectorAll("#table-of-contents a.active-toc-item")
    .forEach((el) => el.classList.remove("active-toc-item"));
  link.classList.add("active-toc-item");
}

Intersection Observer

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      activateById(entry.target.id);
    }
  },
  { threshold: [0.2] }
);

readingHeadings.forEach((h) => observer.observe(h));

This marks a heading as soon as 20% of it enters the viewport, which feels natural for longer paragraphs.

Scroll Fallback

Some browsers throttle observers during fast scrolling. Add a secondary check that runs on scroll and on initial load:

const OFFSET = 120;

function setActiveFromScroll() {
  if (!readingHeadings.length) return;
  let current = readingHeadings[0];
  for (const h of readingHeadings) {
    const box = h.getBoundingClientRect();
    if (box.top - OFFSET <= 0) current = h;
    else break;
  }
  activateById(current.id);
}

window.addEventListener("load", setActiveFromScroll, { once: true });
window.addEventListener("scroll", setActiveFromScroll, { passive: true });

Smooth Scroll on Click

document
  .querySelectorAll('#table-of-contents a[href^="#"]')
  .forEach((a) => {
    a.addEventListener("click", (e) => {
      e.preventDefault();
      const target = document.querySelector(a.getAttribute("href"));
      if (!target) return;
      target.scrollIntoView({ behavior: "smooth", block: "start" });
      activateById(target.id);
    });
  });

Instantly highlighting on click removes lag when the scroll animation starts and ensures readers always know which section they jumped to.

6. Polish the Experience

  • Accessibility: Use focus-visible styles on links and ensure color contrast meets WCAG. The example styles reuse the same palette for hover and active states, guaranteeing consistency.
  • Performance: The observer only watches h1–h3 elements inside articleRoot, minimizing DOM work. The scroll handler is lightweight and marked as passive.
  • Customization: Adjust OFFSET to match your navbar height. If you add deeper heading levels, expand the selector to include h4, etc.

7. Testing Checklist

  1. Run pnpm dev, open a post, and confirm the TOC appears on medium+ breakpoints.
  2. Scroll slowly and quickly; the active state should follow your viewport.
  3. Click TOC items to ensure smooth scrolling and instant highlight.
  4. Toggle dark mode (if available) to verify color adjustments.
  5. Run pnpm astro check before committing to confirm type safety.

Conclusion

By combining Astro’s built-in heading metadata with a dedicated TOC component and a small amount of client-side logic, you can deliver a premium reading experience that keeps readers oriented at all times. This pattern is reusable across every Markdown post—simply pass headings into PostLayout, render TableOfContents, and the script will handle the rest.

Live Table of Blog's Contents

Author

Shayne Wong

Publish Date

11 - 20 - 2025

License

Shayne Wong

Avatar
Shayne Wong

All time is no time when it is past.