← Writing
tutorial·2026-06-07·4 min read

MDX Table of Contents: From rehype Extraction to Sticky Gutter Layout

rehype-extract-toc, Portal-fixed positioning, and scroll-until-prose-pin logic

On this page

Adding a table of contents to article pages sounds simpler than page transitions. The layout alone went through four iterations — float-right invading the prose column, position: fixed scrolling away with the page, Nav rolling off-screen and setting top to -716px

This post documents the final solution and the root cause of each pitfall.

The Goal

  • Extract h2 / h3 headings from MDX at build time
  • Desktop: TOC in the viewport gutter to the right of the 720px article column
  • At page top: TOC aligned with the first line of prose
  • On scroll: pin only after the prose start reaches the pin line; stay visible through the rest of the article
  • Mobile: collapsible <details> block above the prose
  • Highlight the current section (IntersectionObserver)

Stack Choice: Build-Time Extraction, Not Runtime DOM Scanning

Our stack is Next.js 16 + @next/mdx + RSC static rendering. The best community fit:

PackageRole
rehype-slugGenerate heading ids at compile time
@stefanprobst/rehype-extract-tocExtract nested TOC data from the AST
@stefanprobst/rehype-extract-toc/mdxExport tableOfContents as a named MDX export

In next.config.ts, order matters — these must run before Expressive Code:

rehypePlugins: [
'rehype-slug',
'@stefanprobst/rehype-extract-toc',
'@stefanprobst/rehype-extract-toc/mdx',
// ...expressive-code
],

In the page:

const { default: MDXContent, tableOfContents } = await loadPostMDX(locale, slug)

h2 / h3 in mdx-components.tsx must accept id, or anchor links won't work:

h2: ({ children, id }) => (
<h2 id={id} className="... scroll-mt-6">{children}</h2>
),

Pitfall 1: Server Can't Call Functions from Client Files

Initially filterToc() lived in 'use client' TocNav.tsx. Calling it from Server Component Toc.tsx threw:

Attempted to call filterToc() from the server but filterToc is on the client

Fix: Move the pure function to src/lib/toc.ts for shared use.

Pitfall 2: float-right Inside a 720px Column Looks Wrong

The design spec said float: right, but inside a max-width: 720px article container the TOC wraps into the prose flow and fights code blocks for space.

Fix: Keep the article at 720px; position the TOC with position: fixed in the viewport gutter:

.toc-gutter {
position: fixed;
width: 168px;
left: calc(50vw + min(360px, 50vw - 32px) + 32px);
}

Similar to Josh W. Comeau's layout approach — the TOC lives outside the article structure, in the screen margin.

Pitfall 3: fixed Scrolls Away (Portal)

With the TOC inside PageTransition's motion.div, position: fixed is relative to that layer, not the viewport. It scrolls with the page — no sticky behavior at all.

Fix: createPortal to document.body:

return createPortal(gutter, document.body)

Pitfall 4: Nav Rolling Off-Screen Makes top Negative

For a while we used Nav's getBoundingClientRect().bottom + 16 as the pin line when scrollY > 0. But our Nav is not sticky — once it scrolls away, bottom becomes something like -716px, and the TOC flies off the top of the screen.

Fix:

function getPinTop(): number {
const nav = document.querySelector('.site-nav')
if (!nav) return 24
return Math.max(24, nav.getBoundingClientRect().bottom + 16)
}

When Nav is visible, pin below it. After Nav leaves, fall back to 24px from the viewport top.

Pitfall 5: When Exactly to Pin?

The requirement evolved three times:

VersionLogicProblem
Atop = max(pin, proseTop)Breaks when pin goes negative
BPin immediately when scrollY > 0Jumps on first scroll; loses prose alignment
C ✅Always top = max(pin, proseTop) with pin floorSync with prose until pin line, then stick

The final scroll logic is one line:

const pinTop = getPinTop()
const proseTop = prose.getBoundingClientRect().top
setGutterTop(Math.max(pinTop, proseTop))

#article-prose marks the prose start; recalculate on scroll, resize, and ResizeObserver.

Final Architecture

page.tsx
├── PostDetail (720px article + mobile collapsible TOC)
├── Toc variant="fixed" (Portal → body)
└── PostNav
MDX pipeline
rehype-slug → rehype-extract-toc → /mdx → Expressive Code
TocNav (Client)
├── IntersectionObserver → active section highlight
├── getPinTop() → pin line (with floor)
└── max(pin, proseTop) → align + pin

Fixed TOC shows at ≥ 1080px viewport width; narrower screens hide it and show the in-article <details> version instead.

Summary

IssueRoot causeFix
Server calls Client fn'use client' boundaryExtract to lib/toc.ts
TOC invades prosefloat in narrow containerfixed + viewport gutter
Not stickymotion layer breaks fixedPortal to body
Disappears on scrollNav bottom goes negativeMath.max(24, …)
Wrong pin timingEarly scrollY checkmax(pin, proseTop)

If Nav becomes position: sticky later, getPinTop() adapts automatically — no TOC changes needed.