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.astrocomponent that groups headings into parent/child sections - A client-side script that tracks the reader’s progress and adds the
activestate 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.astrofiles.
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:
- Group headings by depth so the directory displays primary sections plus nested subsections.
- Render a styled list with anchor links (
href="#section-id"). - Inject a script that observes scroll position and applies
.active-toc-itemto 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-visiblestyles 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–h3elements insidearticleRoot, minimizing DOM work. The scroll handler is lightweight and marked as passive. - Customization: Adjust
OFFSETto match your navbar height. If you add deeper heading levels, expand the selector to includeh4, etc.
7. Testing Checklist
- Run
pnpm dev, open a post, and confirm the TOC appears on medium+ breakpoints. - Scroll slowly and quickly; the active state should follow your viewport.
- Click TOC items to ensure smooth scrolling and instant highlight.
- Toggle dark mode (if available) to verify color adjustments.
- Run
pnpm astro checkbefore 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.