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
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
And convert it into corresponding DOM elements in the generated HTML file:index.html (partial)
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.
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.
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:
- We'll be ditching the manual creation of the TOC. Instead, it will be dynamically assembled based on
h2
andh3
headers in the document. - 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:
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:
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:
--
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
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..)
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:
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
To visualize it, consider the following diagram:
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 }}
Naive Solution
For more detailed information, see the MDN article on Intersection Observer API.
Consider this implementation using IntersectionObserver
:js/main.js
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:
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)
Visually, what is happening here looks something like this:
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)
I like visual aids, so I've included this diagram to help illustrate how the IntersectionObserver
keeps tabs on the reader's progress.
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.
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!