Build a Right-Side TOC for Docs: Sticky, Scroll-Margin, Active Section Highlighting

Was this helpful?
Ops-grade docs UI

Sticky TOC • Scroll-margin • Active highlight

Build a Right-Side TOC for Docs: Sticky, Scroll-Margin, Active Section Highlighting

Your docs page is long. The header is sticky. Someone clicks a TOC entry and lands under the heading, half-hidden behind the nav bar.
Then the active highlight lies about where they are. It’s not “a small UI bug”; it’s friction that makes people abandon the page.

This is how you build a right-side table of contents that behaves like it’s been on-call: sticky but not annoying, anchor jumps that land correctly,
and active section highlighting that doesn’t thrash the main thread or gaslight your readers.

Opinionated stance: build TOC behavior with standards-first CSS and modern browser APIs. Avoid heavy “scrollspy” libraries unless you already ship them.
Most TOC failures are self-inflicted: bad heading IDs, missing scroll offsets, and fragile scroll listeners.

Table of contents

The TOC you see on the right is built from the headings in this page. If it feels boringly reliable, good. That’s the goal.

Requirements that actually matter

A TOC is a navigation system. Treat it like one. That means it has requirements beyond “looks fine on my screen.”
In practice, you’re optimizing for: correctness, perceived responsiveness, accessibility, and low maintenance.

Correctness requirements

  • Anchor jumps land on the correct visual line, not behind sticky headers. Use scroll-margin-top on headings; stop playing whack-a-mole with JS offsets.
  • Active section highlight matches what users see. “Closest heading to top” is trickier than it sounds on long pages with mixed H2/H3 and code blocks.
  • Deep links survive refactors. Heading IDs must be stable. If your docs generator changes IDs on punctuation or emoji, you’re going to break links in tickets, chat, and muscle memory.

Performance requirements

  • No scroll event hot loops. Your TOC should not turn the page into a space heater. Use IntersectionObserver and throttle the few remaining events.
  • Works on big pages. Docs pages can hit thousands of lines. Handling 200 headings should not be a crisis.
  • No layout thrash. Avoid repeated getBoundingClientRect() calls inside scroll handlers.

UX and accessibility requirements

  • Keyboard navigation works (Tab to TOC, Enter to activate). Don’t build a faux menu that traps focus.
  • Visible focus states. If your TOC highlights “active” but hides “focus,” keyboard users suffer.
  • Honest responsive behavior. On mobile, a right-side TOC becomes a top drawer or collapsible; don’t force a two-column layout that squashes content.

Joke #1: A “scrollspy” is like an intern with binoculars—impressive until you realize it reports everything five seconds late.

Facts and context: why TOCs got weird

TOCs feel simple because we’ve had them forever. But the web kept changing underneath them: sticky headers, SPAs, dynamic content, and layout primitives that didn’t exist 15 years ago.
Here are concrete context points that explain today’s sharp edges.

Fact 1: Early “scrollspy” behavior became popular through frameworks (notably Bootstrap) because browsers didn’t provide a clean, event-driven viewport API.
Fact 2: position: sticky was standardized after years of vendor behavior; “sticky in a scrolling container” still trips teams when an ancestor has overflow set.
Fact 3: The IntersectionObserver API arrived specifically to avoid scroll handlers that forced sync layouts and drained battery on mobile devices.
Fact 4: scroll-margin-top is part of the CSS Scroll Snap era, but it’s broadly useful even when you’re not using scroll snapping.
Fact 5: GitHub popularized stable heading anchors for Markdown rendering, which trained readers to expect copyable deep links everywhere.
Fact 6: The shift to SPAs introduced “route changes without page loads,” so TOC logic needs to re-scan headings after content hydration.
Fact 7: Historically, many browsers treated hash navigation differently for focus management; accessibility bugs here are old and persistent.
Fact 8: Large docs sites often use “virtualized” content or lazy-loaded images; both can shift layout after scroll, breaking naive “active heading” logic.
“Hope is not a strategy.”
General Gordon R. Sullivan

None of this is exotic. The trick is choosing primitives that are stable under change: CSS for offsets, observer APIs for active tracking, and clean markup for accessibility.

Layout: sticky right-side TOC that doesn’t fight the page

The right-side TOC works best when it’s a sibling column in a grid or flex layout, not an absolutely positioned overlay.
Overlay TOCs are why you end up with “why can’t I select text” bugs and mysterious click-through issues.

The minimum viable layout

Use a grid with content and TOC columns. Give the TOC position: sticky and a sane top offset that accounts for your sticky header.
Then make the TOC scroll internally with max-height and overflow: auto so it doesn’t extend past the viewport.

Failure mode: sticky doesn’t stick. Almost always caused by an ancestor with overflow: hidden/auto or a missing height context.
Sticky is picky, not broken.

Container rules that keep you out of trouble

  • Don’t wrap your entire page in a scrolling container unless you have a real reason. Let document.scrollingElement be the browser default.
  • If you must have a scroll container, make TOC highlighting observe that container explicitly (observer root), or your “active” state will lag or break.
  • Keep TOC width fixed-ish, but not hard-coded pixels everywhere. A clamp-based width or a CSS variable makes future tweaks cheap.

Make it responsive without heroics

On smaller screens, the “right side” stops existing. You have two sane options:

  1. Move TOC above the article (simple, reliable, no JS).
  2. Collapsible drawer (more complex; you now own focus management and ARIA states).

If you choose the drawer, treat it like a component with tests. Otherwise it will regress the next time someone “just adjusts padding.”

Anchor navigation: scroll-margin and offset handling

Here’s the most common TOC bug: click a link, the browser scrolls, the sticky header covers the heading.
People then scroll up slightly, which changes the “active” section, and the TOC highlight jumps. That’s not a small glitch; it’s a loop.

Use scroll-margin-top on headings (not JS offsets)

Put this on the headings themselves:

  • h2, h3 { scroll-margin-top: calc(var(--headerHeight) + 16px); }

It works for hash navigation, programmatic element.scrollIntoView(), and user-initiated jumps.
It also scales across pages without needing to remember “add 72px to the offset” in six different functions.

Stabilize heading IDs

Your TOC links are only as reliable as your IDs. In production docs, people paste deep links into tickets.
Then you rename “Cache & Consistency” to “Cache consistency,” your generator changes the slug, and suddenly your support team is doing archaeology.

Do this:

  • Prefer explicitly set IDs when possible (author writes them, generator honors them).
  • Use a deterministic slug function (lowercase, hyphens, strip punctuation) and lock it as an interface contract.
  • When you must change IDs, ship redirects for hashes if your platform allows it (some static site routers can map legacy hashes).

Decide how you want focus to behave

Hash navigation scrolls, but it may not move keyboard focus to the heading. For accessibility, it’s often good to focus the heading (or a hidden anchor),
but you must avoid breaking scroll position by calling focus() without preventScroll.

A pragmatic approach:

  • On TOC click, let the browser scroll to the hash.
  • Then call heading.focus({ preventScroll: true }) on a focusable heading (add tabindex="-1").

If you skip this, keyboard and assistive tech users get a worse experience. If you implement it wrong, you get double-scroll jitter. Choose your poison, then implement it carefully.

Active section highlighting: IntersectionObserver done right

Active highlighting is not a toy feature. It’s how readers keep orientation on long pages.
When it’s wrong, people feel it immediately. They may not file a bug, but they’ll stop trusting your docs.

The model: “active” means closest heading above the top threshold

The naive model is “the heading currently visible.” That breaks when two headings are visible, or when a heading is visible but you’re deep in the section.
A better definition:

  • Set a top threshold line (usually just under the sticky header).
  • The active section is the last heading whose top is above that line.

IntersectionObserver can approximate this by observing headings and tracking which ones have crossed into a “top band.”
You tune this with rootMargin so the observer’s “viewport” starts below the sticky header.

A robust observer strategy

Use an observer for headings (H2 and optionally H3). Maintain a small map of visibility states.
On callback, compute the best active heading based on:

  • Heading order in the document
  • Whether the heading’s bounding box is within a top band
  • Fallback to the first heading when at top, or last heading when near bottom

Don’t: on every scroll, loop all headings and call getBoundingClientRect(). That pattern causes layout thrash and inconsistent frame times on long pages.

Handle “jump to hash” and “back button”

When the page loads with a hash, the browser scrolls before your JS may be ready. Your TOC highlight should still be correct.
That means:

  • On init, set active based on location.hash if it matches a heading ID.
  • After fonts/images settle, re-evaluate once (not forever). A single requestAnimationFrame or setTimeout after load is often enough.

Keep the active TOC entry visible inside the TOC

If the TOC itself scrolls, you should ensure the active item stays in view (subtle auto-scroll).
But do it politely: only scroll the TOC when the active item is out of view, and use scrollIntoView({ block: "nearest" }).

Joke #2: If your TOC highlight lags, congratulations—you’ve invented a status page for scrolling.

Performance and reliability: avoid self-inflicted outages

A docs TOC can hurt real business outcomes. Not through CPU spend, but through trust.
When navigation breaks, people assume the content is also sloppy. And when your docs live inside a product UI, a bad TOC can regress overall app performance.

Where TOCs go to die

  • Dynamic content injection: headings appear after initial render (MDX hydration, client-side fetch). Your TOC needs re-scan and re-observe.
  • Layout shifts: images without dimensions, late-loading web fonts, accordions that expand. Your “active heading” logic must tolerate movement.
  • Nested scrolling containers: main content scrolls inside a div, not the window. Observers need root set, and scroll-margin-top may not match the visible header behavior.
  • Too many observers: creating one observer per heading is wasteful. You want one observer instance watching many targets.

Accessibility traps

A TOC is basically a set of in-page navigation links. Treat it as a <nav aria-label="On this page">.
Keep it simple and semantic. Over-engineering is where ARIA goes to get misused.

  • Use aria-current="true" (or aria-current="location") on the active link.
  • Keep link text short and identical to headings when possible (screen readers shouldn’t have to guess).
  • Ensure focus styles are visible against your background.

SPA and docs platform realities

If you have client-side routing, you need to rebuild the TOC on route change and reattach observers. That means:

  • Listen to route events (framework-specific) and re-run TOC setup.
  • Disconnect observers on teardown to avoid memory leaks.
  • Don’t assume headings exist immediately after navigation; wait for render completion.

SRE lens: if a UI feature requires a complex “init sequence,” it will break during partial deploys, A/B tests, or content experiments.
Make the TOC tolerant of missing headings and delayed content.

Fast diagnosis playbook

When the TOC is broken in production, you don’t have time for philosophical debates about scroll APIs.
You need a quick funnel that finds the bottleneck: CSS layout, anchor offsets, observer logic, or platform lifecycle.

First: is sticky working?

  • Open the page, scroll, confirm the TOC stays pinned below the header.
  • If it doesn’t: inspect ancestors for overflow and transforms. Sticky fails silently when the layout context is wrong.

Second: do anchor jumps land correctly?

  • Click a mid-page TOC item. If the heading is hidden under the header, you’re missing scroll-margin-top (or you’re applying it to the wrong element).
  • If it lands correctly but then “jumps” again, you likely have competing JS calling scrollIntoView() or focus without preventScroll.

Third: is active highlighting correct and stable?

  • Scroll slowly through a boundary between sections. If the highlight flickers, your observer thresholds or rootMargin are wrong.
  • If it never updates, the observer may be observing the wrong root (window vs scroll container) or headings aren’t being observed after hydration.

Fourth: does it break only on some pages?

  • Compare pages with different content types: many code blocks, images, nested headings, or collapsible panels.
  • Look for heading ID collisions (duplicate headings) and invalid IDs (spaces, punctuation) if your generator is sloppy.

Fifth: does it break only after navigation in an SPA?

  • If initial load works but subsequent routes don’t, you’re not reinitializing or not disconnecting old observers.
  • If it works after hard refresh but not client navigation, your code is running before headings render.

Common mistakes (symptom → root cause → fix)

Symptom: clicking a TOC link lands “too high” or “too low”

Root cause: missing scroll-margin-top, or you applied it to the wrong element (like a wrapper div rather than the heading). Sometimes the sticky header height changes with breakpoints.

Fix: apply scroll-margin-top directly to h2/h3 (or the anchored element) using a CSS variable for header height per breakpoint.

Symptom: TOC highlight is wrong near the bottom of the page

Root cause: the last heading never “intersects” the observer’s band, especially if rootMargin/threshold is tuned for mid-page behavior.

Fix: add a bottom sentinel element, or special-case “near bottom” by checking scroll position vs scrollHeight, then force last heading active.

Symptom: highlight flickers rapidly between two headings

Root cause: thresholds too sensitive; headings are close together; layout shifts (images) cause tiny scroll changes. Another culprit is mixing H2 and H3 without a consistent “active” rule.

Fix: use a single threshold strategy (top band), reduce observed targets (track only H2 for active, optionally mark H3 as secondary), and debounce updates to animation frames.

Symptom: sticky TOC stops sticking on some pages

Root cause: an ancestor has overflow: hidden/auto, or transform set, creating a new containing block that changes sticky behavior.

Fix: remove the overflow/transform from the ancestor, or move the sticky element outside that context. If you must keep it, use a different layout strategy (e.g., fixed + padding) but expect more work.

Symptom: TOC entries don’t match headings after content updates

Root cause: TOC generated at build time, but content injected at runtime; or headings are changed via client-side rendering after TOC initialization.

Fix: generate TOC at runtime after render, or observe DOM mutations (sparingly) and re-scan headings on meaningful changes.

Symptom: clicking TOC causes “double scroll” jitter

Root cause: both default hash navigation and a JS handler call scrollIntoView(). Or you focus the heading without preventScroll.

Fix: choose one navigation method. If using hashes, let the browser scroll and only add focus with preventScroll.

Symptom: the page feels slow only while scrolling

Root cause: scroll handler doing layout reads/writes repeatedly. Or too many DOM updates (class toggling) per scroll tick.

Fix: move to IntersectionObserver, update the DOM only when active heading changes, and batch class changes.

Symptom: duplicate headings break deep links

Root cause: your slug generator emits the same ID for identical headings, so links point to the first occurrence.

Fix: disambiguate IDs by appending a counter suffix deterministically (e.g., -2, -3).

Three corporate mini-stories from the trenches

Incident: the wrong assumption about “the scroll container”

A company shipped a documentation portal embedded inside their product UI. It looked modern: fixed top nav, left rail, and a clean right-side TOC.
It also had a custom scroll container because the product team wanted the whole app frame to feel “native.”

The TOC highlight worked perfectly in staging. In production, it was nonsense: sometimes the active item never changed; sometimes it jumped two sections at a time.
Support filed it as “sporadic” because it depended on viewport height, which is the kind of bug that enjoys being misdiagnosed.

The wrong assumption was subtle: the TOC implementation used IntersectionObserver with the default root (the viewport).
But the actual scrolling happened inside div.app-scroll. From the browser’s perspective, the headings weren’t moving relative to the viewport in the same way the team expected.

The fix was boring and immediate: set the observer’s root to the scroll container, and use a rootMargin that accounted for the sticky header inside the same container.
They also removed one extra nested overflow wrapper that was blocking sticky positioning for the TOC itself.

Postmortem takeaway: if you build custom scrolling, you own all the side effects. “Scroll container” isn’t a detail; it’s the main character.

Optimization that backfired: “let’s precompute everything on scroll”

Another org had a docs site with heavy technical content—lots of code blocks and API references.
They noticed the TOC highlight felt laggy in low-end laptops, so someone “optimized” it by caching every heading’s Y-position at page load.

That change tested well on a small set of pages. Then it rolled out to the whole docs corpus.
A week later, the reports started: click a TOC link, it lands correctly, but the highlight is off by one section. Worse on pages with diagrams.

Here’s what happened: images and fonts loaded after the initial cache. Layout shifted, headings moved, but the cached Y-positions didn’t.
The logic stayed fast, sure. It was also confidently wrong—arguably the worst kind of wrong.

They reverted the caching and moved to IntersectionObserver. Where they still needed offsets (a special “scroll to next section” button), they computed positions lazily, just-in-time, and never assumed the layout was stable until after load.

Takeaway: performance work that ignores layout shifts isn’t optimization; it’s time travel to a past DOM that no longer exists.

Boring but correct practice that saved the day: stable IDs and a compatibility layer

A team migrated from one Markdown renderer to another to gain better syntax highlighting.
New renderer, new slug rules. Suddenly, old deep links from tickets and runbooks started failing.
Not a catastrophic outage, but it hit the people who were already stressed: on-call engineers looking up procedures.

The team that avoided pain had done two boring things earlier:
first, they required explicit IDs for top-level runbook headings; second, they kept a small compatibility map for legacy hashes that redirected to new IDs.

During the migration, they ran an automated check over the corpus: extract every heading ID before and after, diff them, and generate mappings for the common breakages.
Where they couldn’t map reliably, they refused the change until the authors provided explicit IDs.

The result was dull. No surprise broken links. Support didn’t notice. On-call didn’t notice. That’s the win.

Takeaway: stable anchors are operational data. Treat them with the same seriousness as API compatibility.

Practical tasks (commands, outputs, decisions)

Below are tasks you can run today on a typical Linux workstation or CI runner to diagnose and prevent TOC regressions.
Each task includes: a command, what the output means, and the decision you make from it.
I’m using a static output directory called dist/ and a source directory called docs/. Adjust names; keep the intent.

Task 1: Confirm that headings have IDs (basic hygiene)

cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23][^>]*>' dist | head
dist/guide.html:118:<h2 id="requirements-that-actually-matter">Requirements that actually matter</h2>
dist/guide.html:176:<h2 id="facts-and-context">Facts and context: why TOCs got weird</h2>
dist/guide.html:265:<h3 id="the-minimum-viable-layout">The minimum viable layout</h3>

What it means: you’re seeing headings with id="...". If IDs are missing, your TOC links will be fragile or impossible.

Decision: if IDs are absent, fix your renderer/build pipeline to emit deterministic IDs or require explicit IDs in authoring.

Task 2: Detect headings without IDs (the failure list)

cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23](?![^>]*\sid=)[^>]*>' dist | head
dist/faq.html:44:<h2>FAQ</h2>
dist/intro.html:90:<h3 class="note">A subtle caveat</h3>

What it means: those headings lack IDs.

Decision: either (a) generate IDs at build time, or (b) exclude those headings from the TOC generator to avoid dead links.

Task 3: Find duplicate IDs (silent link corruption)

cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import Counter
ids = []
for fn in glob.glob("dist/**/*.html", recursive=True):
    s = open(fn, "r", encoding="utf-8").read()
    ids += re.findall(r'\bid="([^"]+)"', s)
c = Counter(ids)
dups = [k for k,v in c.items() if v > 1]
print("duplicate ids:", len(dups))
print("\n".join(dups[:20]))
PY
duplicate ids: 2
faq
overview

What it means: at least two IDs are repeated across pages (or within a page). Within a page is the real problem for hash navigation.

Decision: ensure IDs are unique per page. If duplicates are cross-page only, it’s fine. If within-page, change your slugger to add suffixes.

Task 4: Verify scroll-margin-top exists in built CSS

cr0x@server:~$ rg -n --glob 'dist/**/*.css' 'scroll-margin-top' dist | head
dist/assets/site.css:211:h2{scroll-margin-top:calc(var(--headerHeight) + 16px)}
dist/assets/site.css:212:h3{scroll-margin-top:calc(var(--headerHeight) + 16px)}

What it means: your build output includes the offset rule.

Decision: if missing, add it to the global stylesheet for docs content, not a one-off page.

Task 5: Check if sticky is being defeated by overflow on ancestors

cr0x@server:~$ rg -n --glob 'dist/**/*.html' 'overflow:\s*(auto|hidden|scroll)' dist | head
dist/assets/site.css:88:.shell{overflow:hidden}
dist/assets/site.css:132:.content-wrap{overflow:auto}

What it means: you have overflow rules that may create sticky containment contexts.

Decision: audit layout wrappers. If the TOC is inside an element with overflow other than visible, sticky behavior may change. Move the sticky element or remove the overflow wrapper.

Task 6: Confirm your TOC links match real IDs

cr0x@server:~$ python3 - <<'PY'
import bs4, glob
from bs4 import BeautifulSoup

for fn in ["dist/guide.html"]:
    s = open(fn, "r", encoding="utf-8").read()
    soup = BeautifulSoup(s, "html.parser")
    ids = {t.get("id") for t in soup.select("[id]")}
    bad = []
    for a in soup.select("aside#toc a[href^='#']"):
        h = a.get("href")[1:]
        if h and h not in ids:
            bad.append(h)
    print(fn, "bad toc hrefs:", bad[:20])
PY
dist/guide.html bad toc hrefs: []

What it means: TOC anchors resolve to elements on the page.

Decision: if there are bad hrefs, your TOC generator is out of sync with the renderer, or headings are being added/removed after TOC build.

Task 7: Detect “hash links” that point nowhere site-wide

cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import defaultdict

by_page_ids = {}
for fn in glob.glob("dist/**/*.html", recursive=True):
    s = open(fn, "r", encoding="utf-8").read()
    ids = set(re.findall(r'\bid="([^"]+)"', s))
    by_page_ids[fn] = ids

broken = []
for fn in glob.glob("dist/**/*.html", recursive=True):
    s = open(fn, "r", encoding="utf-8").read()
    for href in re.findall(r'href="#([^"]+)"', s):
        if href not in by_page_ids[fn]:
            broken.append((fn, href))
print("broken in-page hashes:", len(broken))
for x in broken[:10]:
    print(x[0], "#"+x[1])
PY
broken in-page hashes: 1
dist/faq.html #top

What it means: at least one in-page hash link doesn’t correspond to an element ID on that page.

Decision: add the missing ID target (e.g., id="top") or remove the link.

Task 8: Measure how many headings you’re observing (scale check)

cr0x@server:~$ python3 - <<'PY'
import glob, re
fn = "dist/guide.html"
s = open(fn, "r", encoding="utf-8").read()
h2 = len(re.findall(r'<h2\b', s))
h3 = len(re.findall(r'<h3\b', s))
print("h2:", h2, "h3:", h3, "total:", h2+h3)
PY
h2: 12 h3: 27 total: 39

What it means: this page has 39 headings. Observing 39 elements is fine. Observing 400 might still be fine, but you need to be deliberate.

Decision: if heading counts are huge, consider observing only H2 for active section, and treat H3 as navigation-only (no active tracking), or implement a smarter selection.

Task 9: Confirm you’re not shipping a scroll handler that runs every tick

cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'addEventListener\(\s*["'\'']scroll["'\'']' dist | head
dist/assets/toc.js:14:window.addEventListener('scroll', onScroll)

What it means: you are attaching a scroll listener.

Decision: inspect what it does. If it reads layout and updates DOM on every scroll event, replace with IntersectionObserver or throttle to animation frames and update only on active change.

Task 10: Confirm IntersectionObserver is used (and not per-heading)

cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'new\s+IntersectionObserver' dist
dist/assets/toc.js:38:const io = new IntersectionObserver(onIntersect, { root: null, rootMargin: '-72px 0px -70% 0px', threshold: [0, 1] })

What it means: the build includes an IntersectionObserver instance.

Decision: confirm it is a single observer reused across headings. If you see it created inside a loop, fix that.

Task 11: Check Lighthouse/PSI-style performance locally (quick and dirty)

cr0x@server:~$ node -e "console.log('Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.');"
Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.

What it means: yes, this is a placeholder command—but the decision is real: measure scroll performance with tooling, not vibes.

Decision: if scroll produces long tasks, inspect TOC code first. It’s a common culprit because it runs during scroll by definition.

Task 12: Verify headings are in a single main landmark for screen readers

cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<main\b' dist | head
dist/guide.html:52:<main>
dist/faq.html:18:<main>

What it means: the page uses a <main> landmark, which improves navigation for assistive tech.

Decision: if missing, add it. Then ensure your TOC is a <nav aria-label="On this page"> landmark, not a random div.

Task 13: Verify the active TOC item sets aria-current

cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'aria-current' dist
dist/assets/toc.js:97:link.setAttribute('aria-current', isActive ? 'location' : 'false')

What it means: your JS toggles aria-current.

Decision: if it’s not present, add it. If it sets invalid values, fix it to location or remove the attribute when inactive.

Task 14: Detect heading text changes that would break stable IDs

cr0x@server:~$ git diff --word-diff -- docs/guide.md | head -n 40
diff --git a/docs/guide.md b/docs/guide.md
--- a/docs/guide.md
+++ b/docs/guide.md
@@
-## Active section highlighting: IntersectionObserver done right
+## Active section highlighting with IntersectionObserver

What it means: a heading changed. If your IDs are derived from heading text, the ID likely changed too.

Decision: either keep stable explicit IDs or accept a breaking change and manage it (redirects/mappings). Don’t let it drift silently.

Task 15: Confirm SPA route changes reinitialize TOC (smoke test via logs)

cr0x@server:~$ rg -n --glob 'src/**/*.ts' 'initToc\(|setupToc\(' src | head
src/toc/init.ts:12:export function initToc(){ ... }
src/router.ts:48:router.on('routeChangeComplete', () => initToc())

What it means: TOC init is called after route change.

Decision: if absent, add it. If present but buggy, ensure it disconnects previous observers and handles delayed render.

Checklists / step-by-step plan

Step-by-step: ship a TOC that behaves

  1. Define your heading policy. Pick which levels appear (H2 only, or H2+H3). Decide on stable IDs.
  2. Implement scroll offset in CSS. Add scroll-margin-top on headings using a header-height variable.
  3. Build semantic markup. TOC is a <nav aria-label="On this page"> with a list of links.
  4. Make the TOC sticky. Use grid layout; apply position: sticky, top offset, max-height, and internal scrolling.
  5. Active highlighting with IntersectionObserver. One observer instance, many heading targets, tuned rootMargin.
  6. Set aria-current. Use aria-current="location" on active link; remove when inactive.
  7. Handle hash-on-load. On init, if location.hash matches a heading, set it active immediately.
  8. Handle SPA navigation. Re-scan headings on route change and disconnect old observers.
  9. Test layout shift cases. Pages with images, code blocks, and collapsibles. Confirm no flicker.
  10. Guardrails in CI. Check for missing IDs, duplicate IDs, and broken in-page hash links.

Checklist: production hardening

  • TOC works when JavaScript fails (links still scroll via hash).
  • Sticky header height is consistent (or CSS variable changes per breakpoint).
  • IDs are stable and deterministic; duplicates are disambiguated.
  • Active highlight doesn’t flicker under slow scrolling.
  • TOC doesn’t cause long tasks during scroll.
  • Keyboard navigation and focus states are visible.

Checklist: what not to do

  • Don’t attach a scroll listener that recalculates all heading positions every tick.
  • Don’t store cached offsets unless you also track layout shifts (which you probably won’t do correctly).
  • Don’t build a “drawer TOC” on mobile unless you’re ready to own ARIA, focus trapping, and escape behavior.
  • Don’t let heading IDs change casually. That’s a breaking change; treat it like one.

FAQ

1) Should I generate the TOC at build time or runtime?

Build time is simpler and faster, but only if your rendered headings are stable and not injected after load.
If you have MDX hydration or client-side content, do runtime generation (or do build-time plus a runtime reconcile).

2) Is scroll-margin-top supported enough to rely on?

Yes for modern browsers. If you support very old browsers, you’ll need fallback behavior, but most doc portals can require modern engines.
The bigger risk isn’t support; it’s forgetting to apply it to the actual anchored element.

3) Why not just use a scroll event handler?

You can, but you’ll reinvent half of IntersectionObserver poorly: throttling, layout reads, and edge cases around the bottom of the page.
Scroll handlers also tend to regress because “it works” until you add one more content type.

4) My TOC highlight is off by one section. What’s the usual culprit?

Root margin not accounting for sticky header height, or your “active” definition is “any visible heading” rather than “last heading above a threshold line.”
Tune rootMargin and settle on a deterministic rule.

5) Should I highlight H3 items too?

Only if it helps readers. On very dense pages, highlighting H3 can flicker because H3 headings are close together.
A common compromise: active is the current H2; within that, highlight the nearest H3 only if it’s comfortably spaced.

6) How do I avoid breaking deep links when headings change?

Use explicit IDs for important sections (runbooks, APIs, troubleshooting). If you can’t, keep a compatibility layer that maps old hashes to new IDs.
Otherwise, accept that you’re shipping a breaking change and communicate it.

7) What about pages with collapsible sections or tabs?

Collapsibles complicate “active” because content visibility changes. The safe path: only include headings that are always present in the flow.
If you include headings inside collapsibles, your TOC must understand open/closed state and update observers accordingly.

8) Why does sticky fail only on some pages?

Because some pages have a wrapper that sets overflow or transform and changes sticky’s containing block.
Sticky isn’t global; it’s contextual. Audit ancestor styles, not the sticky element.

9) Do I need to auto-scroll the TOC sidebar to keep active item visible?

It’s a nice-to-have if your TOC is long. Do it only when needed (block: "nearest"), and don’t fight the user if they’re manually scrolling the TOC.

10) How do I test this reliably?

Use deterministic pages: one with lots of headings, one with big images, one with code blocks, one with collapsibles.
Add automated checks for broken hash links and duplicate IDs. For active highlighting, do browser tests that scroll to known offsets and assert aria-current.

Conclusion: next steps you can ship

A right-side TOC is one of those features that seems cosmetic until it breaks and everyone becomes a reluctant UI detective.
The fix is not more JavaScript. It’s picking the right primitives and enforcing boring rules.

  1. Add scroll-margin-top to headings using a header-height variable that matches your sticky header.
  2. Ensure stable, unique heading IDs (explicit where it matters; deterministic slugging everywhere else).
  3. Make the TOC sticky via grid layout, and avoid overflow wrappers that sabotage sticky.
  4. Use a single IntersectionObserver to drive active highlighting, with rootMargin tuned under the header.
  5. Ship CI guardrails for missing IDs, duplicate IDs, and broken in-page hash links.

If you do just two things: use scroll-margin-top and stop running heavy logic in a scroll handler.
That alone eliminates most TOC bugs I’ve seen in production.

← Previous
Factory Overclocks: Marketing Trick or Real Value?
Next →
VPN + Port Forwarding: Expose Services Safely Without Turning Your VPN Into a Hole

Leave a comment