Adding a Table of Contents to an article can be useful to see at a glance which topics the article covers.
Scott Spence recently wrote an in depth guide on how to add a table of contents to a Gatsby blog that uses mdx.
I took the ideas from that post, expanded on them and implemented a table of contents for my blog.
If all went well, you will see a section called "TABLE OF CONTENTS" floating next to the text of this article, updating the styles to indicate which heading you're currently on.
Starting point
The following guide starts off from a functioning Gatsby blog that uses mdx (by using gatsby-plugin-mdx
).
The same thing can be done with some adjustments if you are using regular markdown through gatsby-transformer-remark.
Getting the data
As a wise man once said: "Get the data, do the thing!".
The goal is to create an object that lists every heading on the page, along with the corresponding CSS-id for that heading.
Add CSS-ids to all headings
By default, headings (<h1>
s to <h6>
s) do not have a css id
tied to them.
We want to add one to each heading. It doesn't matter how we accomplish this, only that each heading has an id
.
That id
will later be used in an anchor-tag to link to that heading on the page.
I did this by adding the remark-slug
plugin.
<h2>Puppies are cute</h2>
will turn into <h2 id="puppies-are-cute">Puppies are cute</h2>
, opening the door to link to that point in the page with <a href="#puppies-are-cute">Puppies are cute</a>
.
const remarkSlug = require(`remark-slug`);
// ...
{
resolve: `gatsby-plugin-mdx`,
options: {
remarkPlugins: [remarkSlug]
}
}
// ...
Querying for the tableOfContents
object.
gatsby-plugin-mdx
allows you to query for a field called tableOfContents
.
import { graphql } from "gatsby";
// ...
export const blogPostTemplateQuery = graphql`
query PostBySlug($slug: String!) {
mdx(fields: { slug: { eq: $slug } }) {
// ...
body
tableOfContents
// ...
}
}
`;
The tableOfContents
object lists the text of all headings in the document (eg. Puppies are awesome
), along with the link to their corresponding CSS-id (eg. #puppies-are-awesome
).
Lower level headings are nested under their higher level parents (an h3 will be nested under an h2).
Example
To visualize this better, an example!
For an .mdx
file with the following headings:
## First h2
### First h3 under first h2
#### First h4 under first h3
### Second h3 under first h2
## Second h2
### First h3 under second h2
The resulting tableOfContents
object would look like:
"items": [
{
"url": "#first-h2",
"title": "First h2",
"items": [
{
"url": "#first-h3-under-first-h2",
"title": "First h3 under first h2",
"items": [
{
"url": "#first-h4-under-first-h3",
"title": "First h4 under first h3"
}
]
},
{
"url": "#second-h3-under-first-h2",
"title": "Second h3 under first h2"
}
]
},
{
"url": "#second-h2",
"title": "Second h2",
"items": [
{
"url": "#first-h3-under-second-h2",
"title": "First h3 under second h2"
}
]
}
]
Using the data
Once you query the tableOfContents
for a blogpost, pass it down to its own component.
Since the tableOfContents
object can be empty if there are no headings, conditionally render the <TableOfContents />
component.
{
mdx?.tableOfContents?.items && (
<TableOfContents items={mdx.tableOfContents.items} />
);
}
One level of headings
The items
prop is an array filled with objects.
Scroll up for a reminder of how those objects look.
For now, let's iterate over that top-level array and render the first level of headings.
function TableOfContents(props) {
return (
<details>
<summary>Table of Contents</summary>
<ol>
{props.items.map((item) => (
<li key={item.url}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ol>
</details>
);
}
Nested heading levels
How do we get the nested levels of headings to also show up?
Drumroll 🥁🥁🥁 ... RECURSION!
Recursion is one of those scary words for a concept that seems very complicated at first and suddenly clicks.
This video by Computerphile explains it beautifully
Each item
we previously rendered can have an items
array within it.
This can continue up to 6 times (h1 to h6).
To account for that, we'll repeat the logic we wrote to display a single level of headings.
First, a little refactor
function renderItems(items) {
return (
<ol>
{items.map((item) => (
<li key={item.url}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ol>
);
}
function TableOfContents(props) {
return (
<details>
<summary>Table of Contents</summary>
{renderItems(props.items)}
</details>
);
}
In the renderItems
function, check if the current item in the loop has an items
property on it.
If it does, repeat the same logic for those items
.
function renderItems(items) {
return (
<ol>
{items.map((item) => (
<li key={item.url}>
<a href={item.url}>{item.title}</a>
{item.items && renderItems(item.items)}
</li>
))}
</ol>
);
}
The nested list goes inside a list item, according to the MDN docs
Styling the active heading
To style the link to the active heading differently from all the other headings in the table of contents, we first have to find out which one is currently active.
You can mark a heading as "active" when it's visible to the user, when it's in the viewport.
The Intersection Observer API is an ideal tool for this.
An IntersectionObserver
works by first setting up the logic for it, and then telling it to start watching an element (eg. an anchor tag).
After telling an IntersectionObserver
to keep track of an element, it will fire a callback function every time it is triggered (eg. the anchor tag enters the viewport).
Get all the heading-ids
To tell the InterSectionObserver
which element to observe, it expects a reference to a DOM-element.
Getting references to all heading elements in the table of contents can be done by calling document.getElementById()
with each heading's CSS-id.
Luckily, all the information needed for this is already there, inside the tableOfContents
object.
The following helper function will take in an items
array from the tableOfContents
object and return a flat array that contains all the CSS-ids.
function getIds(items) {
return items.reduce((acc, item) => {
if (item.url) {
// url has a # as first character, remove it to get the raw CSS-id
acc.push(item.url.slice(1));
}
if (item.items) {
acc.push(...getIds(item.items));
}
return acc;
}, []);
}
Recursion was used again, to repeat the logic for nested items.
For our example above, the resulting array from getIds(props.items)
in the <TableOfContents items={tableOfContents.items}/>
component would be:
[
"first-h2",
"first-h3-under-first-h2",
"first-h4-under-first-h3",
"second-h3-under-first-h2",
"second-h2",
"first-h3-under-second-h2",
];
Get the active heading's id
We can use the data we just gathered in order to get the data we're really after: which heading is active right now?.
We'll keep track of which heading is active inside a React custom hook called useActiveId
.
This hook returns a piece of state that holds the id of the heading that's currently active.
import { useEffect, useState } from "react";
function useActiveId(itemIds) {
const [activeId, setActiveId] = useState(``);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: `0% 0% -80% 0%` }
);
itemIds.forEach((id) => {
observer.observe(document.getElementById(id));
});
return () => {
itemIds.forEach((id) => {
observer.unobserve(document.getElementById(id));
});
};
}, [itemIds]);
return activeId;
}
Using the gathered data
Back in the function for the <TableOfContents />
component, the active id can be used to change the styling of the active heading.
Pass the id of the active heading to the renderItems
function.
function TableOfContents(props) {
const idList = getIds(props.items);
const activeId = useActiveId(idList);
return (
<details open>
<summary>Table of Contents</summary>
{renderItems(props.items, activeId)}
</details>
);
}
Inside the renderItems
function, pass the activeId
down to the recursive call of the function.
Check if the id for the current element is the same as the one passed in as activeId
and adjust the styling accordingly.
function renderItems(items, activeId) {
return (
<ol>
{items.map((item) => (
<li key={item.url}>
<a
href={item.url}
style={{
color: activeId === item.url.slice(1) ? "white" : "tomato",
}}
>
{item.title}
</a>
{item.items && renderItems(item.items, activeId)}
</li>
))}
</ol>
);
}
Remember, the url of an item starts with a #, while the active id passed into
that function does not.
All the code
import React, { useEffect, useState } from "react";
function getIds(items) {
return items.reduce((acc, item) => {
if (item.url) {
// url has a # as first character, remove it to get the raw CSS-id
acc.push(item.url.slice(1));
}
if (item.items) {
acc.push(...getIds(item.items));
}
return acc;
}, []);
}
function useActiveId(itemIds) {
const [activeId, setActiveId] = useState(`test`);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: `0% 0% -80% 0%` }
);
itemIds.forEach((id) => {
observer.observe(document.getElementById(id));
});
return () => {
itemIds.forEach((id) => {
observer.unobserve(document.getElementById(id));
});
};
}, [itemIds]);
return activeId;
}
function renderItems(items, activeId) {
return (
<ol>
{items.map((item) => {
return (
<li key={item.url}>
<a
href={item.url}
style={{
color: activeId === item.url.slice(1) ? "tomato" : "green",
}}
>
{item.title}
</a>
{item.items && renderItems(item.items, activeId)}
</li>
);
})}
</ol>
);
}
function TableOfContents(props) {
const idList = getIds(props.items);
const activeId = useActiveId(idList);
return (
<details open>
<summary>Table of Contents</summary>
{renderItems(props.items, activeId)}
</details>
);
}
export default TableOfContents;
Top comments (0)