A table of contents is a list of links that allows you to quickly jump to specific sections of content on the same page. It benefits long-form content because it shows the user a handy overview of what content there is with a convenient way to get there.
This tutorial will show you how to parse long Markdown text to HTML and then generate a list of links from the headings. After that, we will make use of the Intersection Observer API to find out which section is currently active, add a scrolling animation when a link is clicked, and finally, learn how Vue’s <transition-group>
allow us to create a nice animated list depending on which section is currently active.
Parsing Markdown
On the web, text content is often delivered in the form of Markdown. If you haven’t used it, there are lots of reasons why Markdown is an excellent choice for text content. We are going to use a markdown parser called marked, but any other parser is also good.
We will fetch our content from a Markdown file on GitHub. After we loaded our Markdown file, all we need to do is call the marked(<markdown>, <options>)
function to parse the Markdown to HTML.
async function fetchAndParseMarkdown() {
const url = 'https://gist.githubusercontent.com/lisilinhart/e9dcf5298adff7c2c2a4da9ce2a3db3f/raw/2f1a0d47eba64756c22460b5d2919d45d8118d42/red_panda.md'
const response = await fetch(url)
const data = await response.text()
const htmlFromMarkdown = marked(data, { sanitize: true });
return htmlFromMarkdown
}
After we fetch and parse our data, we will pass the parsed HTML to our DOM by replacing the content with innerHTML
.
async function init() {
const $main = document.querySelector('#app');
const htmlContent = await fetchAndParseMarkdown();
$main.innerHTML = htmlContent
}
init();
Generating a list of heading links
Now that we’ve generated the HTML, we need to transform our headings into a clickable list of links. To find the headings, we will use the DOM function querySelectorAll('h1, h2')
, which selects all <h1>
and <h2>
elements within our markdown container. Then we’ll run through the headings and extract the information we need: the text inside the tags, the depth (which is 1 or 2), and the element ID we can use to link to each respective heading.
function generateLinkMarkup($contentElement) {
const headings = [...$contentElement.querySelectorAll('h1, h2')]
const parsedHeadings = headings.map(heading => {
return {
title: heading.innerText,
depth: heading.nodeName.replace(/\D/g,''),
id: heading.getAttribute('id')
}
})
console.log(parsedHeadings)
}
This snippet results in an array of elements that looks like this:
[
{title: "The Red Panda", depth: "1", id: "the-red-panda"},
{title: "About", depth: "2", id: "about"},
// ...
]
After getting the information we need from the heading elements, we can use ES6 template literals to generate the HTML elements we need for the table of contents.
First, we loop through all the headings and create <li>
elements. If we’re working with an <h2>
with depth: 2
, we will add an additional padding class, .pl-4
, to indent them. That way, we can display <h2>
elements as indented subheadings within the list of links.
Finally, we join the array of <li>
snippets and wrap it inside a <ul>
element.
function generateLinkMarkup($contentElement) {
// ...
const htmlMarkup = parsedHeadings.map(h => `
<li class="${h.depth > 1 ? 'pl-4' : ''}">
<a href="#${h.id}">${h.title}</a>
</li>
`)
const finalMarkup = `<ul>${htmlMarkup.join('')}</ul>`
return finalMarkup
}
That’s all we need to generate our link list. Now, we will add the generated HTML to the DOM.
async function init() {
const $main = document.querySelector('#content');
const $aside = document.querySelector('#aside');
const htmlContent = await fetchAndParseMarkdown();
$main.innerHTML = htmlContent
const linkHtml = generateLinkMarkup($main);
$aside.innerHTML = linkHtml
}
Adding an Intersection Observer
Next, we need to find out which part of the content we’re currently reading. Intersection Observers are the perfect choice for this. MDN defines Intersection Observer as follows:
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.
So, basically, they allow us to observe the intersection of an element with the viewport or one of its parent’s elements. To create one, we can call a new IntersectionObserver()
, which creates a new observer instance. Whenever we create a new observer, we need to pass it a callback function that is called when the observer has observed an intersection of an element. Travis Almand has a thorough explanation of the Intersection Observer you can read, but what we need for now is a callback function as the first parameter and an options object as the second parameter.
function createObserver() {
const options = {
rootMargin: "0px 0px -200px 0px",
threshold: 1
}
const callback = () => { console.log("observed something") }
return new IntersectionObserver(callback, options)
}
The observer is created, but nothing is being observed at the moment. We will need to observe the heading elements in our Markdown, so let’s loop over them and add them to the observer with the observe()
function.
const observer = createObserver()
$headings.map(heading => observer.observe(heading))
Since we want to update our list of links, we will pass it to the observer
function as a $links
parameter, because we don’t want to re-read the DOM on every update for performance reasons. In the handleObserver
function, we find out whether a heading is intersecting with the viewport, then obtain its id
and pass it to a function called updateLinks
which handles updating the class of the links in our table of contents.
function handleObserver(entries, observer, $links) {
entries.forEach((entry)=> {
const { target, isIntersecting, intersectionRatio } = entry
if (isIntersecting && intersectionRatio >= 1) {
const visibleId = `#${target.getAttribute('id')}`
updateLinks(visibleId, $links)
}
})
}
Let’s write the function to update the list of links. We need to loop through all links, remove the .is-active
class if it exists, and add it only to the element that’s actually active.
function updateLinks(visibleId, $links) {
$links.map(link => {
let href = link.getAttribute('href')
link.classList.remove('is-active')
if(href === visibleId) link.classList.add('is-active')
})
}
The end of our init()
function creates an observer, observes all the headings, and updates the links list so the active link is highlights when the observer notices a change.
async function init() {
// Parsing Markdown
const $aside = document.querySelector('#aside');
// Generating a list of heading links
const $headings = [...$main.querySelectorAll('h1, h2')];
// Adding an Intersection Observer
const $links = [...$aside.querySelectorAll('a')]
const observer = createObserver($links)
$headings.map(heading => observer.observe(heading))
}
Scroll to section animation
The next part is to create a scrolling animation so that, when a link in the table of contents is clicked, the user is scrolled to the heading position rather abruptly jumping there. This is often called smooth scrolling.
Scrolling animations can be harmful if a user prefers reduced motion, so we should only animate this scrolling behavior if the user hasn’t specified otherwise. With window.matchMedia('(prefers-reduced-motion)')
, we can read the user preference and adapt our animation accordingly. That means we need a click event listener on each link. Since we need to scroll to the headings, we will also pass our list of $headings
and the motionQuery
.
const motionQuery = window.matchMedia('(prefers-reduced-motion)');
$links.map(link => {
link.addEventListener("click",
(evt) => handleLinkClick(evt, $headings, motionQuery)
)
})
Let’s write our handleLinkClick
function, which is called whenever a link is clicked. First, we need to prevent the default behavior of links, which would be to jump directly to the section. Then we’ll read the href
attribute of the clicked link and find the heading with the corresponding id
attribute. With a tabindex
value of -1 and focus()
, we can focus our heading to make the users aware of where they jumped to. Finally, we add the scrolling animation by calling scroll()
on our window.
Here is where our motionQuery
comes in. If the user prefers reduced motion, the behavior will be instant
; otherwise, it will be smooth
. The top
option adds a bit of scroll margin to the top of the headings to prevent them from sticking to the very top of the window.
function handleLinkClick(evt, $headings, motionQuery) {
evt.preventDefault()
let id = evt.target.getAttribute("href").replace('#', '')
let section = $headings.find(heading => heading.getAttribute('id') === id)
section.setAttribute('tabindex', -1)
section.focus()
window.scroll({
behavior: motionQuery.matches ? 'instant' : 'smooth',
top: section.offsetTop - 20
})
}
Animate the list of links
For the last part, we will make use of Vue’s <transition-group>
, which is very useful for list transitions. Here is Sarah Drasner’s excellent intro to Vue transitions if you’ve never worked with them before. They are especially great because they provide us with animation lifecycle hooks with easy access to CSS animations.
Vue automatically attaches CSS classes for us when an element is added (v-enter
) or removed (v-leave
) from a list, and also with classes for when the animation is active (v-enter-active
and v-leave-active
). This is perfect for our case because we can vary the animation when subheadings are added or removed from our list. To use them, we will need wrap our <li>
elements in our table of contents with an <transition-group>
element. The name attribute of the <transition-group>
defines how the CSS animations will be called, the tag attribute should be our parent <ul>
element.
<transition-group name="list" tag="ul">
<li v-for="(item, index) in activeHeadings" v-bind:key="item.id">
<a :href="item.id">
{{ item.text }}
</a>
</li>
</transition-group>
Now we need to add the actual CSS transitions. Whenever an element is entering or leaving it, should animate from not visible (opacity: 0
) and moved a bit to the bottom (transform: translateY(10px)
).
.list-enter, .list-leave-to {
opacity: 0;
transform: translateY(10px);
}
Then we define what CSS property we want to animate. For performance reasons, we only want to animate the transform
and the opacity
properties. CSS allows us to chain the transitions with different timings: the transform
should take 0.8 seconds and the fading only 0.4s.
.list-leave-active, .list-move {
transition: transform 0.8s, opacity 0.4s;
}
Then we want to add a bit of a delay when a new element is added, so the subheadings fade in after the parent heading moved up or down. We can make use of the v-enter-active
hook to do that:
.list-enter-active {
transition: transform 0.8s ease 0.4s, opacity 0.4s ease 0.4s;
}
Finally, we can add absolute positioning to the elements that are leaving to avoid sudden jumps when the other elements are animating:
.list-leave-active {
position: absolute;
}
Since the scrolling interaction is fading elements out and in, it’s advisable to debounce the scrolling interaction in case someone is scrolling very quickly. By debouncing the interaction we can avoid unfinished animations overlapping other animations. You can either write your own debouncing function or simply use the lodash debounce function. For our example the simplest way to avoid unfinished animation updates is to wrap the Intersection Observer callback function with a debounce function and pass the debounced function to the observer.
const debouncedFunction = _.debounce(this.handleObserver)
this.observer = new IntersectionObserver(debouncedFunction,options)
Here’s the final demo
Again, a table of contents is a great addition to any long-form content. It helps make clear what content is covered and provides quick access to specific content. Using the Intersection Observer and Vue’s list animations on top of it can help to make a table of contents even more interactive and even allow it to serve as an indication of reading progress. But even if you only add a list of links, it will already be a great feature for the user reading your content.
Hello. Awesome technology. How can I implement this in pure JS, without Babel?
If Babel is the problem then it’s already pure JavaScript: clicking “View Compiled” returns almost the same code (it just changes formatting). I think who made the post used “Babel” just by habit. In the other hand it requires Vue so some may not call it “pure JavaScript”.
This is great! It’s a simple but beautiful implementation that works well. Thanks! This gave me a couple of ideas.
Its amazing how similar your solution is to what ive come up with in platformOS documentation website, even names are almost the same.
https://github.com/mdyd-dev/platformos-documentation/blob/master/src/js/toc.js
I have never seen this solution, but it makes sense since the major part of it is vanilla JS and the problem directs you to go a certain way. Originally I wrote the first prototype in Vue and then rewrote the different parts to vanilla JS for this tutorial.
Really Interesting & Detailed! Love it ^_^