In content-driven websites using markdown generators, adding an interactive table of contents (TOC) necessitates some work with the DOM generated from Markdown. This is primarily because of the way in which Markdown parsers translate Markdown content into markup.

This document is a tutorial for creating an interactive TOC for Markdown-generated content.

Introduction

Demo

To test this implementation, I have created an 11ty example project on Stackblitz simple NodeJS server.

Please feel free to explore the source code on StackBlitz and run the application locally.

Markdown Parsers

Overview

Markdown to HTML

A Markdown parser translates Markdown-formatted text into another format, typically HTML. This is a key function in many Static Site Generators (SSGs), converting user-friendly Markdown into web-ready content.

Here's an example of how a parser would process Markdown input:

content.md
    *.md    
---
# h1 Heading 8-)
## h2 Heading

## Typographic replacements

Enable typographer option to see result.

(c) (C) (r) (R) (tm) (TM) (p) (P) +-

test.. test... test..... test?..... test!....

!!!!!! ???? ,,  -- ---

"Smartypants, double quotes" and 'single quotes'

And convert it into corresponding DOM elements in the generated HTML file:

index.html (partial)
    *.html    
<hr>
<h1>h1 Heading 😎</h1>
<h2>h2 Heading</h2>

<h2>Typographic replacements</h2>
<p>Enable typographer option to see result.</p>
<p>© © ® ® ™ ™ (p) (P) ±</p>
<p>test… test… test… test?.. test!..</p>
<p>!!! ??? ,  – —</p>
<p>“Smartypants, double quotes” and ‘single quotes’</p>
---

CMS Applications

In the context of CMS for SSGs, content is often managed using a Markdown extension known as Front Matter. Front Matter Markdown files contain metadata at the top of the file, enclosed between triple-dashed lines.

    *.md    
---
title: This is my post title.
description: This is a post on My Blog.
date: 2018-09-30
tags: second tag
layout: layouts/post.njk
---

It can include various data fields such as title, date, layout, categories, etc., which can be used to control how the page is built.

Let's take a closer look at how some popular SSGs utilize Markdown parsers and Front Matter, and where an interactive TOC may be applicable:

  • 11ty (Eleventy)
    • Eleventy is a streamlined SSG that transforms a directory of templates, including those written in Markdown, into HTML. It's known for its simplicity and flexibility, allowing developers to use a variety of template languages.
  • Astro
    • Astro is a modern front-end framework designed for building fast, optimized websites. It stands out by allowing developers to write components in their preferred JavaScript framework (or even vanilla JS). At build time, these components, along with any Markdown files, are rendered into static HTML.
  • Docusaurus
    • Docusaurus is a robust tool primarily used for building community-driven websites and project documentation. It has built-in support for Markdown, making it easy for developers and content creators to generate rich, structured content.

Requirements

Feature Overview

An interactive table of contents (TOC) provides a navigational roadmap to your content, allowing readers to jump to specific sections of a page with ease. It's especially useful for long-form content, where scrolling can become tedious.

Diagram of interactive TOC

As depicted in the diagram, an interactive TOC typically appears as a sidebar or a floating menu, listing the headings in the document. When a user clicks on a heading in the TOC, the page automatically scrolls to that section.

--

Functional Requirements

In this tutorial, we'll leverage 11ty and the official eleventy-plugin-toc to dynamically generate a TOC from the Markdown headings, generating our base header navigation code.

To summarize our requirements:

  1. We'll be ditching the manual creation of the TOC. Instead, it will be dynamically assembled based on h2 and h3 headers in the document.
  2. The solution must be template-driven, meaning it should operate based on a predefined document structure.

A quick tip: The code generated by the 11ty plugin can be utilized anywhere or manually implemented if you prefer. For other frameworks like Astro, similar packages such as rehype-toc are readily available.

While it's not the main focus of this tutorial, it's worth noting that the conversion of headings into anchor tags will also be managed by a plugin. Here's how you can set it up:

    *.js    
// .eleventy.js

const markdownIt = require("markdown-it");
const markdownItAnchor = require("markdown-it-anchor");

// ...
let markdownLibrary = new markdownIt({
  html: true,
  typographer: false,
  linkify: true,
}).use(markdownItAnchor, {
      tabIndex: false,
      permalink: markdownItAnchor.permalink.headerLink({
        safariReaderFix: true,
      });
})

Identifying Hurdles

I was inspired to write this guide after noticing that on some websites with interactive Table of Contents (TOC), the mapping to the document's headings sometimes goes awry.

I observed this on one of my favorite blogs for CSS content, Ahmad Shadeed's blog:

In the example screen recording, you can see that the sections remain within the viewport.

Let's visualize how observed elements fit within the viewport:

Element is in intersectionRect mapping to TOC

If we assume the Intersection Observer was used, it appears that the h2 and h3 heading elements were selected in the document, as they correspond with the displayed TOC items.

However, we run into a problem when we encounter sections whose height exceeds the visible viewport - we can visualize it like this:

No element is in viewport intersectionRect

--

Given our requirements, a CSS-only solution just won't do for a dynamic Table of Contents (TOC). This leads us to a more effective solution: the Intersection Observer API.

This API allows us to pinpoint the section and subsection the reader is currently viewing on the page, making it a superior choice over other methods like Scroll Events.

Note: This guide will focus on the Intersection Observer API. If you're curious about why it's a better choice than Scroll Events, check out my previous post on Intersection Observer.

I hope this makes it clear what the challenges are with markdown and creating dynamic table of contents due to the DOM structure generated.

CSS Template

Let's add some styles to our TOC for better visual feedback as we progress.

Feel free to modify it as you please.

TOC Sections

The following styles are for the base table of contents markup:

index.css
    *.css    
// index.css

/* The aside element is given a fixed width and height. */
aside {
  width: 300px;
  position: relative;
  height: 100vh;
}

/* The h4 and nav elements inside the aside are positioned fixedly on the page. */
aside h4,
aside nav {
  position: fixed;
  top: 300px;
}

/* The table of contents and its h4 heading are given a top margin. */
nav.toc,
nav.toc h4 {
  margin-top: 4rem;
}

/* The ordered list inside the table of contents is styled to have custom counters. */
nav.toc ol {
  counter-reset: item;
  list-style-type: none;
  list-style-position: outside;
}

/* The list items inside the table of contents are styled to be clickable and have custom counters. */
nav.toc > li {
  position: relative;
  cursor: pointer;
}

/* The links inside the list items are underlined when hovered over. */
nav.toc ol li a:hover {
  text-decoration: underline;
  text-decoration-skip-ink: auto;
}

/* The list items have custom counters before them. */
nav.toc li::before {
  content: '\25B8  'counters(item, '.') '. ';
  counter-increment: item;
  position: absolute;
  top: 4px;
  left: -2.8em;
  margin-right: 5px;
  font-size: 75%;
  font-weight: var(--fw-normal);
}

/* The links inside the list items are not underlined. */
nav.toc > ol > li > a {
  text-decoration: none;
}

Defining Active Classes

We'll define classes that we'll add programmatically to indicate the associated page content section is in progress. We'll use is-active-parent for h2's, and is-active for h3's.

index.css (cont..)
    *.css    
/* The active link is styled to have a different color and font weight. */
nav li.is-active a {
  color: var(--primary-lighter);
  font-weight: var(--fw-bold);
}

/* The active parent list item has a custom counter before it. */
nav.toc > ol > li.is-active-parent::before {
  content: '\2732  'counters(item, '.') '. ';
  font-weight: var(--fw-bold);
}

/* The link inside the active parent list item is underlined and has a different color and font weight. */
nav.toc ol > li.is-active-parent > a{
  text-decoration: underline;
  font-weight: var(--fw-bold);
  color: var(--primary-lighter);
}

/* The active list item has a custom counter before it with a different color. */
nav.toc li.is-active::before {
  content: '\2732  'counters(item, '.') '. ';
  color: var(--primary-lighter);
}

DOM Structure

TOC Layout

Most Static Site Generators (SSGs) provide a way to integrate a TOC into your project build.

For instance, the eleventy-plugin-toc automatically generates DOM elements based on page content in 11ty.

Here's how you can use it:

    *.js    
const pluginTOC = require("eleventy-plugin-toc");
eleventyConfig.addPlugin(pluginTOC, {
    tabIndex: false,
    tags: ["h2", "h3"],
    wrapper: "nav",
    wrapperClass: "toc",
    flat: false,
  });

This example uses Nunjucks templating library, but this implementation could also be implemented as a UI component using React or Web Components.

I've included an embedded StackBlitz project here to follow along.

Open Editor 💻

Top Level Document Structure

In post.njk, are posts will use this template layout so we will define our page structure here.

Here's the top-level structure of our document:

post.njk / post.html
    *.html    
<div class="content-wrapper">
  <aside>
    <h4>Table of Contents</h4>
    <!-- Template Start -->
    <nav class="toc">
      {# ... #}
    </nav>
    <!-- Template End -->
  </aside>
  <div class="post-content">
    <!-- page content -->
  </div>
</div>

To visualize it, consider the following diagram:

Top level DOM structure of page layout

This diagram shows the top-level DOM structure of our page layout.

The content-wrapper div contains an aside element for the table of contents and a post-content div for the page content.

After writing the content in Markdown, the following structure is generated for the Table of Contents (TOC).

This structure corresponds to the different sections within the content:

Output from {{ content | toc | safe }}
    *.html    
<nav class="toc">
    <ol>
        <li>
          <a href="#section-header">Section Header</a>
        </li>
        <ol>
            <li>
              <a href="#art-of-witty-banter">Art of Witty Banter</a>
                <ol>
                    <li class="is-active">
                      <a href="#mr-witty-whistles">Mr Witty Whistles</a>
                    </li>
                </ol>
            </li>
            <li><a href="#storytelling">Storytelling</a>
                <ol>
                    <li>
                      <a href="#storytelling-subsection">Storytelling Subsection</a>
                    </li>
                </ol>
            </li>
        </ol>
    </ol>
</nav>

Naive Solution

For more detailed information, see the MDN article on Intersection Observer API.

Consider this implementation using IntersectionObserver:

js/main.js
    *.js    
// js/main.js
document.addEventListener('DOMContentLoaded', () => {
  const sections = Array.from(document.querySelectorAll(".post-content h3"));
  const tocItems = Array.from(document.querySelectorAll(".toc li > ol li"));

  // Create an Intersection Observer
  const observer = new IntersectionObserver(
    function (entries) {

      entries.forEach(function (entry) {
        // Get the index of the h3 element
        const index = sections.indexOf(entry.target);

        // Get the corresponding list item
        const tocItem = tocItems[index];
        console.log(entry.target)
        if (entry.isIntersecting) {
          tocItem.classList.add("is-active");
        } else {
          tocItem.classList.remove("is-active");
        }
      });
    },
    {
      threshold: 0.1, // Callback will run when 10% of the target is visible
      rootMargin: "10% 0px 10% 0px" // Intersection area is from 10% to 90% of the viewport
    }
  );

  // Start observing the h3 elements
  sections.forEach(function (section) {
    observer.observe(section);
  });
});

The naive solution has a flaw: when no headers are intersecting, no TOC items are active.

This can be confusing for users, as they might not know where they are in the document.

Just a heads up, we're tackling scenarios where it's not possible to configure a rootMargin to offset the viewport.

We can represent the result of this implementation like this:

Naive Solution Result

We'll improve upon this such that there's always an active TOC item, even when no headers are intersecting.

--

Technically, you could wrap elements between section and subsection tags and observe their intersections. However, this requires a separate development script to monitor markdown changes and adjust the DOM accordingly. This might lead to high CPU usage and additional complexity in ensuring the wrapping doesn't disrupt your application.

If you try this approach, I'd be interested to hear about your experience!

Revised Solution

Let's break down the revised solution.

js/main.js / main.ts (1 of 3)
    *.ts    
interface ExtendedHTMLElement extends HTMLElement {
  h3?: HTMLElement;
}

type NextElementType = ExtendedHTMLElement | null

const sections: NodeListOf<HTMLElement> = document.querySelectorAll(".post-content h3");

// pGroups:  Elements we'll observe for intersections
let pGroups: ExtendedHTMLElement[] = [];

sections.forEach((h3: HTMLElement) => {
  let nextElement: NextElementType = h3.nextElementSibling as ExtendedHTMLElement;

  while (nextElement && nextElement.tagName !== "H3") {
    nextElement.h3 = h3; // Keep track of the h3 this element belongs to
    pGroups.push(nextElement);
    nextElement = nextElement.nextElementSibling as ExtendedHTMLElement;
  }
});

Visually, what is happening here looks something like this:

ExtendedHTMLElement Interface

In essence, this script is grouping elements under their respective h3 sections.

As the reader scrolls, elements associated with their respective h3 sections enter and leave the viewport.

The script keeps track of the current active section and its corresponding table of contents item using two flags: lastActiveSubsectionItem for h3 subsections and lastActiveSectionItem for h2 sections.

Here's the next part of the implementation using the two flags to accurately reflect the reader's current position on the page:

js/main.js / main.ts (2 of 3)
    *.ts    
// h3 mapping
let lastActiveSubsectionItem: HTMLElement | null = null;
// h2 mapping
let lastActiveSectionItem: HTMLElement | null = null;

// Create an Intersection Observer
const observer = new IntersectionObserver(
  (entries: IntersectionObserverEntry[]) => {
    entries.forEach((entry: IntersectionObserverEntry) => {
      const h3 = (entry.target as ExtendedHTMLElement).h3; // Get the h3 this element belongs to
      const index = sections.indexOf(h3 as HTMLElement);
      const tocItem = tocItems[index];

      // Mapping for Content Section from H2
      const sectionItem = tocItem.parentElement instanceof HTMLElement
        ? tocItem.parentElement.closest("ol > li")
        : null;

      if(sectionItem) {
        if (entry.isIntersecting) {
          tocItem.classList.add("is-active");
          lastActiveSubsectionItem = tocItem;
          sectionItem.classList.add("is-active-parent");
          lastActiveSectionItem = sectionItem;
        } else {
          if (tocItem !== lastActiveSubsectionItem) {
            tocItem.classList.remove("is-active");
          }
          if (sectionItem !== lastActiveSectionItem) {
            sectionItem.classList.remove("is-active-parent");
          }
        }
      }
    });
  },
  {
    threshold: 0.1, // Callback will run when 10% of the target is visible
    rootMargin: "-25% 0px 10% 0px", // Intersection area is from 10% to 90% of the viewport
  }
);

I like visual aids, so I've included this diagram to help illustrate how the IntersectionObserver keeps tabs on the reader's progress.

intersection observer callback is triggered

As you navigate through the page, various sibling elements of the h3 subsection headers become visible.

This visibility shift prompts the system to update both the active section and its related entry in the table of contents.

To tie everything together, we initiate the process by having the IntersectionObserver start observing the groups of sibling elements we identified in part 1.

    *.js    
// js/main.js (3 of 3)

  // Same code from parts 1 & 2.
  pGroups.forEach((p) => {
    observer.observe(p);
  });

You may need to adjust rootMargin and threshold values depending on your markup structure and page configuration, but this should provide the groundwork for an interactive table of contents generated from Markdown.

That's all!