One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Adding a sticky table of contents to an Astro blog
Cover image: A map with pins on it — Photo by Joachim Schnürle on Unsplash
astro engineering frontend

Adding a sticky table of contents to an Astro blog

Build-time heading extraction, sticky sidebar, and an IntersectionObserver active state

Published
11 June 2026
Read time
6 min read
SeriesPart of How this blog was built — documenting every decision that shaped this site.

Long blog posts need wayfinding. A sticky table of contents that tracks where you are as you scroll is one of the most useful navigational additions you can make to a reading experience. This post covers how I built one for this site in a way that generates the heading list at build time rather than in client-side JavaScript. The build-time approach means no DOM inspection, no flash of missing content, and no layout shift.

How Astro provides headings

When you render a markdown content entry with render() from astro:content, it returns both the Content component and a headings array. Each item has the shape:

{
depth: number; // 1–6, matching the h1–h6 level
slug: string; // URL-safe id matching the id Astro sets on the element
text: string; // Plain text content of the heading
}

Crucially, the slug values in this array match the id attributes Astro’s Markdown renderer writes onto the heading elements in the output HTML. That means you can generate anchor links at build time without any DOM inspection.

Passing headings to the layout

In the dynamic blog route, destructure headings from render() and forward it to the layout:

src/pages/blog/[id].astro
const { post } = Astro.props;
const { Content, headings } = await render(post);
<MarkdownPostLayout frontmatter={post.data} postId={post.id} headings={headings}>
<Content />
</MarkdownPostLayout>

Filtering and rendering the ToC at build time

In the layout’s frontmatter, filter to h2 and h3 — deep nesting beyond that makes the sidebar more confusing than helpful. Only render the sidebar at all if there are at least two items:

---
const { frontmatter, postId, headings = [] } = Astro.props;
const tocHeadings = (headings as { depth: number; slug: string; text: string }[])
.filter((h) => h.depth === 2 || h.depth === 3);
---

The sidebar markup is static HTML emitted at build time:

{tocHeadings.length > 1 && (
<aside class="post__sidebar" aria-label="Table of contents">
<nav class="toc">
<p class="toc__heading">On this page</p>
<ol class="toc__list">
{tocHeadings.map((h) => (
<li class={`toc__item toc__item--h${h.depth}`}>
<a class="toc__link" href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ol>
</nav>
</aside>
)}

No document.createElement, no innerHTML, no flash of missing content. <ol> is used rather than <ul> because the heading sequence in a document is ordered from top to bottom, not an unordered collection.

Layout structure

Two-column post layout wireframe showing the article content column on the left and the sticky table of contents sidebar on the right with active section highlighting

The sidebar sits alongside the post content in a CSS grid. The two-column layout only activates at wide viewports — below 1280px the sidebar is hidden and the post reads as a single column as usual:

.post__layout {
margin: 0 auto;
@media (min-width: 1280px) {
display: grid;
grid-template-columns: minmax(0, 68ch) 260px;
gap: 4rem;
align-items: start;
justify-content: center;
}
}
.post__layout__content {
min-width: 0;
}
.post__sidebar {
display: none;
@media (min-width: 1280px) {
display: block;
align-self: start;
position: sticky;
top: 6rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
}
}

minmax(0, 68ch) prevents the content column from overflowing its grid cell on narrow-ish wide viewports. align-self: start is required for position: sticky to work on a grid item — without it the item stretches to the full column height and sticky has nothing to scroll within.

position: sticky; top: 6rem on .post__sidebar keeps the ToC in view as the reader scrolls. max-height: calc(100vh - 8rem) with overflow-y: auto ensures very long tables of contents don’t overflow the viewport.

Active section highlighting with IntersectionObserver

The ToC links are plain anchor links in the static HTML. Highlighting the currently-visible section requires a small amount of client-side JavaScript. IntersectionObserver is the right tool — it fires without scroll event listeners and has good performance characteristics:

const tocLinks = document.querySelectorAll<HTMLAnchorElement>('.toc__link');
if (tocLinks.length) {
const setActive = (id: string) => {
tocLinks.forEach((l) =>
l.classList.toggle('is-active', l.getAttribute('href') === `#${id}`)
);
};
const headingEls = document.querySelectorAll<HTMLElement>(
'.post__content h2, .post__content h3'
);
const obs = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActive(entry.target.id);
break;
}
}
},
{ rootMargin: '0px 0px -80% 0px' }
);
headingEls.forEach((h) => obs.observe(h));
}

rootMargin: '0px 0px -80% 0px' shrinks the observable area to the top 20% of the viewport. A heading becomes “active” as soon as it enters that band, which means the active link reflects the section the reader just reached rather than a section far below the current scroll position.

Adding the script

The IntersectionObserver code lives in MarkdownPostLayout.astro inside a plain <script> tag with no attributes:

<script>
// ... ToC observer and other layout scripts
</script>

Astro treats an unattributed <script> tag as a bundled module script. It processes it through the build pipeline, deduplicates it across all pages that use the layout, and defers it automatically. TypeScript is supported without any extra configuration. No is:inline or type="module" attribute is needed.

Styles

h2 and h3 items are visually distinguished by indentation and font size. The toc__heading element uses the site’s pink accent as both its text colour and a top border, tying it visually to the active link indicator. The active state uses the pink accent on text, a left border, and a faint pink background tint — consistent with other interactive indicators on the site:

.toc__heading {
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-pink);
padding-top: 0.75rem;
border-top: 2px solid var(--color-pink);
margin-bottom: 0.75rem;
}
.toc__link {
display: block;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--color-muted);
text-decoration: none;
padding: 0.45rem 0.5rem 0.45rem 0.75rem;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--color-ink);
background-color: var(--color-surface);
}
&.is-active {
color: var(--color-pink);
border-left-color: var(--color-pink);
background-color: rgba(232, 0, 106, 0.06);
}
}
.toc__item--h3 .toc__link {
padding-left: 1.5rem;
font-size: 0.875rem;
}

The result

Posts with two or more h2/h3 headings get a sticky sidebar that renders as static HTML, requires no JavaScript to display, and uses a small IntersectionObserver to keep the active link accurate as the reader scrolls. Posts with fewer headings render without a sidebar, using the full column width.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.