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:
| Package | Role |
|---|---|
rehype-slug | Generate heading ids at compile time |
@stefanprobst/rehype-extract-toc | Extract nested TOC data from the AST |
@stefanprobst/rehype-extract-toc/mdx | Export 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:
| Version | Logic | Problem |
|---|---|---|
| A | top = max(pin, proseTop) | Breaks when pin goes negative |
| B | Pin immediately when scrollY > 0 | Jumps on first scroll; loses prose alignment |
| C ✅ | Always top = max(pin, proseTop) with pin floor | Sync with prose until pin line, then stick |
The final scroll logic is one line:
const pinTop = getPinTop()const proseTop = prose.getBoundingClientRect().topsetGutterTop(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 pipelinerehype-slug → rehype-extract-toc → /mdx → Expressive Code
TocNav (Client)├── IntersectionObserver → active section highlight├── getPinTop() → pin line (with floor)└── max(pin, proseTop) → align + pinFixed TOC shows at ≥ 1080px viewport width; narrower screens hide it and show the in-article <details> version instead.
Summary
| Issue | Root cause | Fix |
|---|---|---|
| Server calls Client fn | 'use client' boundary | Extract to lib/toc.ts |
| TOC invades prose | float in narrow container | fixed + viewport gutter |
| Not sticky | motion layer breaks fixed | Portal to body |
| Disappears on scroll | Nav bottom goes negative | Math.max(24, …) |
| Wrong pin timing | Early scrollY check | max(pin, proseTop) |
If Nav becomes position: sticky later, getPinTop() adapts automatically — no TOC changes needed.