How Table of Contents Generation and Scroll Spy Activation Work in Astro Big Doc
The Astro Big Doc template generates a hierarchical table of contents server-side by transforming flat markdown headings into a nested tree structure, then activates a client-side scroll spy that updates active states based on the user's scroll position within the article.
The Astro Big Doc repository provides a documentation site template that automatically builds a navigation sidebar from page headings. This article explains how the table of contents generation and scroll spy activation work together to create a synchronized reading experience, covering both the server-side hierarchy building and the client-side interaction handling.
Server-Side Table of Contents Generation
The ToC is constructed during the Astro build process by transforming the flat array of headings provided by the Markdown loader into a hierarchical tree suitable for nested navigation menus.
Extracting Headings from Markdown
When Astro renders a page, the Markdown loader attaches an array of heading objects to entry.data.headings. The page component at src/pages/[...url].astro extracts this data, checks for a toc: false frontmatter flag to disable the ToC, and passes the headings to the layout:
// src/pages/[...url].astro
const headings = (Object.hasOwn(entry.data,"toc") && entry.data.toc === false) ? [] : entry.data.headings
<Layout title={entry.data.title} headings={headings} />
Building the Hierarchy with process_toc_list
The Layout.astro component imports process_toc_list from src/layout/layout_utils.js to transform the flat list into a nested menu structure:
// src/layout/Layout.astro
import { process_toc_list } from "./layout_utils";
const toc_menu_items = process_toc_list(headings);
The process_toc_list function performs the following operations:
- Validates input – Returns early with
{ items: [], visible: false }if headings are undefined or empty. - Creates a container – Initializes a
side_menuobject to hold the tree and visibility state. - Converts to tree – Calls
headings_list_to_treeto build the hierarchical structure. - Marks visibility – Sets
visible: truewhen the menu contains items. - Returns the structure – Provides the ready-to-render object to the layout.
Converting Flat Headings to a Tree Structure
The headings_list_to_tree function in src/layout/layout_utils.js constructs the nested hierarchy by linking child headings to their parents based on depth levels:
function headings_list_to_tree(headings, is_toc) {
// Initialise each heading with metadata
for (let element of headings) {
element.items = []; // child array
element.parent = true; // provisional flag
element.expanded = true; // UI state
element.link = is_toc ? `#${element.slug}` : element.link;
}
// Build the tree by linking child → parent
let tree = [];
for (let index = 0; index < headings.length; index++) {
let element = headings[index];
let parent = find_parent(index, headings);
if (parent) {
parent.items.push(element);
} else {
tree.push(element);
}
}
// Clean up leaf nodes
for (let element of headings) {
if (element.items.length === 0) {
element.parent = false;
delete element.items;
delete element.expanded;
}
}
return tree;
}
Key aspects of this transformation include:
- Fragment link creation – Each heading receives a
linkproperty formatted as#${element.slug}, enabling direct browser navigation to section IDs. - Parent detection – The
find_parenthelper walks backwards through the list to locate the nearest heading with a lower depth (e.g., anh2becomes a child of the precedingh1). - Tree output – The resulting structure is a nested array where parent headings contain
itemsarrays of their children, optimized for recursive rendering.
Rendering the Navigation Menu
Layout.astro passes the processed toc_menu_items to the Menu component, which walks the nested structure and emits list items with the toc_href class:
<li><a class="toc_href" href="#my-section">My Section</a></li>
These fragment links enable the browser to scroll directly to the corresponding heading elements when clicked.
Client-Side Scroll Spy Activation
Once the page loads in the browser, the src/layout/toc_menu_activation.js script enhances the static ToC with dynamic scroll tracking and interactive behaviors.
Initializing the Scroll Listener
The activation function attaches to the DOMContentLoaded event and selects the essential DOM elements:
document.addEventListener('DOMContentLoaded', toc_menu_activation, false);
function toc_menu_activation() {
const toc_menu = document.getElementsByClassName("toc_menu")[0];
const article = document.querySelector('article.content');
const hrefs = document.getElementsByClassName("toc_href");
// Build target map
const targets = [...hrefs].map(el =>
article.querySelector(escape_href(el.getAttribute('href')))
);
// Attach scroll listener
article.addEventListener("scroll", (event) => {
// Active section detection logic
});
}
Mapping Links to Heading Elements
Because CSS selectors cannot handle IDs beginning with digits without escaping, the script uses a utility function to safely construct query selectors:
function escape_href(href){
// the # bothers CSS.escape in case of id starting with a number
return `#${CSS.escape(href.replace('#',''))}`;
}
This ensures that fragment identifiers like #123-section are properly escaped for document.querySelector, preventing selector syntax errors.
Detecting the Active Section
The scroll event listener identifies which heading is currently in view by finding the first element whose offsetTop exceeds the article's scrollTop:
article.addEventListener("scroll", (event) => {
let spy = null;
// Find the first heading that has passed the top of the viewport
for (let t = 0; t < targets.length; t++) {
if (targets[t].offsetTop > article.scrollTop) {
spy = targets[t];
break;
}
}
if (spy) {
const id = spy.id;
// Remove previous active states
document.querySelector(".toc_href.active")?.classList.remove("active");
document.querySelector(`a[href="#${id}"].toc_href`)?.classList.add("active");
// Highlight the heading element itself
document.getElementById(id)?.classList.add("active");
}
});
This logic maintains synchronization between the document scroll position and the navigation sidebar by toggling the .active class on both the ToC link and the corresponding heading element.
Handling Submenu Toggle and Hover States
Beyond scroll tracking, the activation script manages collapsible sections and visual feedback:
Submenu Expansion:
Click handlers on elements with the .expand class toggle the visibility of nested lists:
const toggler = document.getElementsByClassName("expand");
for (let i = 0; i < toggler.length; i++) {
toggler[i].addEventListener("click", function() {
this.parentElement.querySelector("ul").classList.toggle("hidden");
this.parentElement.classList.toggle("expanded");
});
}
Hover Synchronization: Mouse events on ToC links temporarily highlight the corresponding headings for visual feedback:
for (let element of hrefs) {
element.addEventListener('mouseenter', (e) => {
const id = e.target.getAttribute('href').replace('#', '');
document.getElementById(id)?.classList.add('hover');
});
element.addEventListener('mouseout', (e) => {
const id = e.target.getAttribute('href').replace('#', '');
document.getElementById(id)?.classList.remove('hover');
});
}
Complete Implementation Example
Integrating the server-side generation with client-side activation requires coordinating the data flow from markdown through to the browser:
Server-side data preparation (src/pages/[...url].astro):
---
import Layout from '../layout/Layout.astro';
const entry = /* fetch markdown entry */;
const headings = (Object.hasOwn(entry.data,"toc") && entry.data.toc === false)
? []
: entry.data.headings;
---
<Layout title={entry.data.title} headings={headings} />
Client-side activation (src/layout/toc_menu_activation.js):
function toc_menu_activation() {
const article = document.querySelector('article.content');
const links = document.querySelectorAll('.toc_href');
// Build target map with proper escaping
const targets = [...links].map(l =>
article.querySelector(`#${CSS.escape(l.getAttribute('href').slice(1))}`)
);
// Scroll spy handler
article.addEventListener('scroll', () => {
let active = null;
for (let i = 0; i < targets.length; i++) {
if (targets[i].offsetTop > article.scrollTop) {
active = targets[i];
break;
}
}
if (active) {
document.querySelector('.toc_href.active')?.classList.remove('active');
document.querySelector(`a[href="#${active.id}"].toc_href`)?.classList.add('active');
}
});
}
document.addEventListener('DOMContentLoaded', toc_menu_activation);
Summary
- Server-side generation in
src/layout/layout_utils.jstransforms the flatentry.data.headingsarray into a nested tree usingprocess_toc_listandheadings_list_to_tree, creating fragment links for each heading. - Hierarchy building links child headings to parents based on depth levels, producing a recursive structure with
items,parent, andexpandedproperties optimized for navigation rendering. - Client-side activation in
src/layout/toc_menu_activation.jsmaps ToC links to their target headings usingescape_hrefto handle CSS selector edge cases with numeric IDs. - Scroll spy logic detects the active section by iterating through heading offsets and comparing them to the article's scroll position, toggling
.activeclasses to maintain visual synchronization. - Interactive features include collapsible submenus controlled by
.expandclick handlers and hover effects that temporarily highlight headings when their corresponding ToC links are moused over.
Frequently Asked Questions
How does the ToC handle pages with no headings or disabled table of contents?
The page component in src/pages/[...url].astro checks for toc: false in the frontmatter or an empty headings array. When either condition is met, it passes an empty array to Layout.astro, which causes process_toc_list to return { items: [], visible: false }, effectively suppressing the sidebar navigation rendering.
Why does the scroll spy use offsetTop comparisons instead of Intersection Observer?
The implementation in src/layout/toc_menu_activation.js uses a scroll event listener with offsetTop comparisons to determine the first heading that has passed the top of the viewport. This approach provides synchronous updates tied directly to the article element's scroll position, ensuring immediate visual feedback without the threshold complexity of Intersection Observer, which is particularly suitable for documentation sites with dense heading structures.
How are heading IDs with special characters (like starting with numbers) handled?
The escape_href utility function in src/layout/toc_menu_activation.js sanitizes fragment identifiers by stripping the hash and applying CSS.escape() before reconstructing the selector. This ensures that headings with IDs beginning with digits or containing special characters that would invalidate CSS selectors are properly escaped for document.querySelector, preventing runtime errors.
Can the ToC support multiple levels of nested headings?
Yes, the headings_list_to_tree function in src/layout/layout_utils.js recursively organizes headings into a tree structure based on their depth levels. The algorithm uses find_parent to locate the nearest preceding heading with a lower depth (e.g., an h2 becomes a child of the preceding h1), supporting unlimited nesting levels while cleaning up leaf nodes by removing empty items arrays to optimize the data structure.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →