Active highlighting
When you scroll a long docs page, the sidebar TOC should tell you where you are — “Installation” lights up when its heading is near the top, then “Usage” takes over. This is called scroll-spy, and in nuxt-toc it is driven by a browser IntersectionObserver.
You do not need to configure anything for the default behavior, but when you have a sticky header you will want to tune two props: rootMargin and scrollOffset.
Default behavior (no props)
<TableOfContents :toc="page?.body?.toc" />
<!-- equivalent to: -->
<TableOfContents
:toc="page?.body?.toc"
:scroll-spy="true"
root-margin="0px 0px -80% 0px"
:scroll-offset="0"
/>What happens on the client (src/runtime/components/TableOfContents.vue:543):
- After
onMounted+nextTick, the component creates oneIntersectionObserverwithroot: null(viewport),threshold: 0, androotMarginfrom props/module defaults. - It walks
displayTocand observes only heading IDs that actually appear in the TOC (viacollectTocIds→document.getElementById(id)). Unrelated page headings are never watched — cheaper and less noisy. - It batches
IntersectionObserverentries through arequestAnimationFrameflush (pendingEntries → flush → activeTocIds Set). Only when membership changes does Vue re-render — this avoids thrashing. - The “active” link(s) receive CSS classes;
lastVisibleHeadingkeeps the last intersecting heading so one item stays highlighted even in the gap between sections (see below).
Which headings are watched?
Not “h2/h3” blindly. The observer watches whatever IDs are in your TOC tree after depth limiting. If your Content searchDepth includes h4 and depth >= 3, h4 headings will be watched too. If the TOC is depth: 1, only top-level headings are observed.
Keeping one item always active
IntersectionObserver fires both entering and leaving events. Between two headings neither may be intersecting for a frame — the set would briefly be empty and no item would look active. The component avoids this flicker with lastVisibleHeading:
// src/runtime/components/TableOfContents.vue:422
function isActive(id: string) {
return activeIdSet.value.has(id) || id === lastVisibleHeading.value
}Actionable meaning: style .active-toc-item and you will always have at least one highlighted link after the first intersection.
Tuning for a sticky header
If your page has a fixed header (e.g. 64px tall), the default 0px 0px -80% 0px may light up the next section too early, and clicking a TOC link may scroll the heading under the header.
Two props solve this — they handle different phases of the interaction:
| Prop | When it matters | What it does |
|---|---|---|
rootMargin | While scrolling (observer) | Shrinks/expands the viewport zone where a heading counts as “visible”. Default shrinks the bottom 80%, so a heading is active when it is near the top. |
scrollOffset | When clicking a link (and on initial hash) | Pixels to subtract from the scroll target so the heading lands below the sticky header. Also applied in applyInitialHash when you land on /page#section. |
Typical sticky header (64px):
<TableOfContents :toc="page?.body?.toc" :scroll-offset="72" root-margin="0px 0px -70% 0px" />And pair it with CSS so native hash links also offset correctly:
/* Makes headings leave room for the sticky header */
.content :deep(h2),
.content :deep(h3) {
scroll-margin-top: 72px;
}See src/runtime/utils/scroll-to-heading.ts:53 — click handling computes rect.top + scrollY - offset and calls window.scrollTo({ top, behavior }), then quietly updates the hash with history.replaceState.
Scroll props reference
All three can be set per-component or globally under nuxtToc in nuxt.config.ts — per-component wins.
// nuxt.config.ts — global defaults
export default defineNuxtConfig({
nuxtToc: {
scrollSpy: true,
rootMargin: '0px 0px -80% 0px',
smooth: false,
scrollOffset: 0,
},
}):scroll-spy="false" — disable highlighting
Turns the observer off entirely (observeSections disconnects and clears activeTocIds). Useful if you want a static TOC:
<TableOfContents :toc="page?.body?.toc" :scroll-spy="false" />rootMargin — the “active zone”
String with CSS margin syntax (four values + unit). The default 0px 0px -80% 0px means expand top/right/left by 0, shrink bottom by 80% — so only the top 20% of the viewport counts.
- Makes active jump earlier: shrink less (e.g.
-60%). - Makes active jump later: shrink more (e.g.
-85%). - Sticky header at top: also offset top, e.g.
-72px 0px -70% 0pxto push the zone below the header.
Watch the key that triggers a rebuild: `${observedIdsKey}|${resolvedRootMargin}` (TableOfContents.vue:556). Changing rootMargin alone forces a fresh observer.
smooth and scrollOffset — click behavior
Controlled in TableOfContents.vue:696 and scroll-to-heading.ts:35:
<!-- Smooth animation, 64px header -->
<TableOfContents :toc="page?.body?.toc" smooth :scroll-offset="64" />- If both are falsy (
smooth=false+scrollOffset=0), clicks use the browser’s native anchor jump — no interception. - If either is set,
onTocClickcallsevent.preventDefault()and usesscrollToHeading(id, { smooth, offset })so the heading lands exactly where you want.
Test tuning quickly
Open the content-v3 playground (npm run dev:v3 → http://localhost:3000/props) — it has a matrix of scrollSpy, rootMargin, smooth, and scrollOffset so you can see the effect before touching your own app.
Lifecycle and rebuilds
Understanding these prevents “why does highlighting lag?” confusion:
- Initial build:
onMounted → nextTick → observeSections(true)→applyInitialHash(). - Late headings:
scheduleLateHeadingRetriesre-runsobserveSections(true)after 50ms/200ms/500ms to catch headings that mount slightly after Content hydration (dev warning listed if an expected ID has no DOM node:warnMissingHeadingIds). - Reactive rebuild:
watch([observedIdsKey, resolvedRootMargin, scrollSpyEnabled, hasLinks], () => scheduleObserveSections())— changing TOC data, swapping pages, or togglingscrollSpyrebuilds the observer on the next frame. - Cleanup:
onUnmounted(() => disconnectObserver())cancelsrequestAnimationFrame, timeouts, and the observer itself — no leaks when navigating between pages.
Styling the active state
The classes applied per item (see src/runtime/components/TocTree.vue:24):
<div class="toc-item toc-topitem active-toc-item active-toc-topitem">…</div>Per-item wrapper also gets id="toc-item-${id}" so a single section can be targeted:
/* Color the whole item */
.active-toc-item {
color: #4ade80;
font-weight: 600;
}
/* Only the top-level (h2) highlight */
.active-toc-topitem {
border-left: 2px solid currentColor;
}
/* Only nested (h3+) highlight */
.active-toc-sublist-item {
opacity: 1;
}
/* Pinpoint one heading */
#toc-item-custom-section {
scroll-margin-top: 80px;
}See Styling and Custom active styles recipe for a full selector table.
When active highlighting does not appear
scrollSpyisfalse(prop ornuxtToc.scrollSpyglobal) — set it totrue.- TOC is empty (
links: []) — no IDs to observe. Checkpage.body.toc.linksin Vue devtools. - Heading elements lack
idattributes — Content normally adds them, but custom prose components that overrideh2may dropid. Restore:id="id"in your component. - Heading IDs differ from TOC link IDs — Content generates one from the heading text; if you override
:id, keep them in sync. A dev warning lists missing DOM nodes.
Next: Styling shows every stable selector you can theme.
