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_menu object to hold the tree and visibility state.
  • Converts to tree – Calls headings_list_to_tree to build the hierarchical structure.
  • Marks visibility – Sets visible: true when 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 link property formatted as #${element.slug}, enabling direct browser navigation to section IDs.
  • Parent detection – The find_parent helper walks backwards through the list to locate the nearest heading with a lower depth (e.g., an h2 becomes a child of the preceding h1).
  • Tree output – The resulting structure is a nested array where parent headings contain items arrays 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
  });
}

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.js transforms the flat entry.data.headings array into a nested tree using process_toc_list and headings_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, and expanded properties optimized for navigation rendering.
  • Client-side activation in src/layout/toc_menu_activation.js maps ToC links to their target headings using escape_href to 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 .active classes to maintain visual synchronization.
  • Interactive features include collapsible submenus controlled by .expand click 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →