Building a table of contents component
For your Gatsby blog posts and notes
This post is a deep-dive into a small part of my Code Notes Gatsby theme, it assumes you have a basic knowledge of Gatsby and how to query data in GraphQL.
I wanted to provide a table of contents for each note, which like many you’ve seen before on documentation sites, provides a helpful way to navigate large notes. See a screengrab of how they look here:
Gatsby provides access to the very useful tableOfContents
GraphQL query when using markdown or mdx. A simplified example query for a single note looks like this:
export const pageQuery = graphql`
query NoteById($id: String!) {
mdx(id: { eq: $id }) {
body
frontmatter {
title
}
tableOfContents
}
}
`;
With that, my page component now has access to the tableOfContents
data, no matter how deep the heading levels go. Using gatsby-plugin-mdx we are able to query the depth of the headings too, like so: tableOfContents(maxDepth: 3)
. This would only return h1-h3
headings. Below you can see some example headings taken from my article on Kickoff’s custom grids.
{
"items": [
{
"url": "#the-difference-between-the-kickoffs-grid-and-block-grids",
"title": "The difference between the Kickoff’s grid and block grids"
},
{
"url": "#creating-your-own-version",
"title": "Creating your own version",
"items": [
{
"url": "#sass-to-the-rescue",
"title": "Sass to the rescue",
"items": [
{
"url": "#include-column3",
"title": "@include column(3);"
},
{
"url": "#percentage12",
"title": "percentage(1/2);"
}
]
}
]
},
{
"url": "#modifying-your-creation-with-changes-to-viewport-width",
"title": "Modifying your creation with changes to viewport width",
"items": [
{
"url": "#tipsforkickoff",
"title": "#tipsForKickoff"
}
]
}
]
}
You will notice that the returned data is an array of objects, with the same nested structure for subheadings. This is where it gets a little more complex when we want to render the table of contents on our page. Because of this nested structure, we first need to loop through the first level array, then check if there are child arrays within each item and so on and so forth.
Creating the components
To achieve this, I created three separate components, and for the purposes of this example, they are not styled in any way. The first was the initial Contents
component which is passed the entire tableOfContents
data through props. I ensured that nothing would render if there was a note that doesn’t use any headings (e.g. an empty array).
export const Contents = ({ tableOfContents }) => {
if (!tableOfContents.items) {
return null;
}
return <ContentsList items={tableOfContents.items} />;
};
The next two components render a list of contents items (ContentsList
) and an individual content item (ContentsItem
).
const ContentsList = ({ items }) => {
return (
<ul>
{items.map((item) => {
return <ContentsItem key={`${item.url}-item`} item={item} />;
})}
</ul>
);
};
const ContentsItem = ({ item }) => (
<li>
<a href={item.url}>{item.title}</a>
{/**
* conditionally render another `ContentsList` within this `<li>`
* if there is a `items` array within this `item`
*/}
{item?.items?.length ? (
<ContentsList key={`${item.url}-list`} items={item.items} />
) : null}
</li>
);
The components above are fairly simple, but the important part is where the ContentsList
is conditionally rendered based on if there are any child items
data for a given item. No guarding is necessary in the ContentsList
component because it would never render based on the other guards in place.
Usage
Now we have the three components setup, all that’s left is to add the Contents
component to your page.
<Contents toc={tableOfContents} />
On reflection the Contents
component could be removed if you conditionally render the ContentsList
component — it depends what you prefer at this point:
{
tableOfContents.items && <ContentsList items={tableOfContents.items} />;
}
You can take a look at how my Contents
component is used in gatsby-theme-code-notes here.