# How Table of Contents Generation and Scroll Spy Activation Work in Astro Big Doc

> Learn how Astro Big Doc generates a table of contents server-side, then activates a client-side scroll spy to highlight active states as you scroll.

- Repository: [Micro Web Stacks/astro-big-doc](https://github.com/microwebstacks/astro-big-doc)
- Tags: internals
- Published: 2026-03-07

---

**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](https://github.com/microwebstacks/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:

```astro
// 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`](https://github.com/microwebstacks/astro-big-doc/blob/main/src/layout/layout_utils.js) to transform the flat list into a nested menu structure:

```astro
// 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`](https://github.com/microwebstacks/astro-big-doc/blob/main/src/layout/layout_utils.js) constructs the nested hierarchy by linking child headings to their parents based on depth levels:

```javascript
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:

```html
<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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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:

```javascript
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:

```javascript
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`:

```javascript
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:

```javascript
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:

```javascript
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`):

```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`](https://github.com/microwebstacks/astro-big-doc/blob/main/src/layout/toc_menu_activation.js)):

```javascript
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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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`](https://github.com/microwebstacks/astro-big-doc/blob/main/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.