How to Add a ToC in Gatsby

Haseeb Majid
Nov 11, 2020 · 6 min read

A lot of people, I included, are using Gatsby to build their own blogs. One of the things I wanted to add to my blog was a table of contents (ToC) 📝. A ToC will show you all the headings of an article and when you click on a heading it’ll take you directly to that heading. It’s a nice little feature to have on your blog, which makes it easier for users to navigate and find the information they are looking for.

https://www.youtube.com/watch?v=YrUeiD4YO5E

Prerequisite

# If you don't have the CLI installed, run this command.
npm -g install gatsby-cli

gatsby new my-gatsby-project https://github.com/gatsbyjs/gatsby-starter-blog

If you already have an existing Gatsby site, you can make the changes directly there instead of using this starter.

Plugins

yarn add gatsby-remark-autolink-headers gatsby-plugin-emotion

The plugin gatsby-remark-autolink-headers turns all of the headers into anchor links. This means we can link to the headers.

Note: You only need to add the emotion plugin if you want to use emotionjs, which is a css-in-js solution. You will see this later when we look at the toc.js component.

`gatsby-plugin-emotion`,
`gatsby-plugin-smoothscroll`,
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [
// ...
`gatsby-remark-autolink-headers`,
],
},
},
// ...

Our header elements with the autolinks plugin will now look something like:

<h1 id="header-1" style="position:relative;">
<a href="#header-1" aria-label="header 1 permalink" class="anchor before"
>...</a
>Header 1
</h1>

ToC

Let’s break this component down. It receives a heading props, which it expects to be a list of the headings from the markdown documents. A heading is an element starting with #, the more #s the lower the heading, for example:

# Heading 1

## Heading 2

### Heading 3

We use the heading.map which will create an element for each of the headings in the list. If it's a "heading 5" or lower, we will simply return an empty div, heading.depth > 4. This is so that the ToC doesn't become too "big" and which would make it harder to use/navigate.

const ToC = ({ headings }) => (
<Toc>
<Title>Table of contents</Title>
<InnerScroll>
{headings.map((heading) => {
if (heading.depth > 4) {
return <div />;
}

return (
<ToCElement key={heading.value}>
<ToCLink
href={`#${heading.value.replace(/\s+/g, "-").toLowerCase()}`}
>
{heading.value}
</ToCLink>
</ToCElement>
);
})}
</InnerScroll>
</Toc>
);

If it’s a heading 1–4, we create a list element (<li>) with a link (<a>) inside of it. This will be a single heading within our ToC. Below is an example ToC:

The heading data for the ToC above will look something like this:

const headings = [
{
value: "Header 1",
depth: 1,
},
{
value: "Header 2",
depth: 2,
},
];

As discussed earlier we are using the autolink headers plugin. This plugin auto-generates anchor links for all of our header. We will use the href attribute to link to these headers in our ToC.

Note: The href link we replace all the whitespace with - so "Heading 1" becomes the anchor link #heading-1.

<ToCLink href={`#${heading.value.replace(/\s+/g, "-").toLowerCase()}`}>
{heading.value}
</ToCLink>

Twin Macro & EmotionJS

yarn add twin.macro @emotion/core @emotion/styled
npx tailwind init
vim package.json
"babelMacros": {
"twin": {
"config": "tailwind.config.js",
"preset": "emotion",
"debugProp": true,
"debugPlugins": false,
"debug": false
}
}

Info: TailwindCSS with Gatsby Gatsby have a good tutorial here, on how to integrate TailwindCSS with a Gatsby site.

The twin.macro library allows us to use TailwindCSS. Tailwind provides us with many pre-generated classes that we can then leverage within our code. Here I am assuming you are somewhat familiar with how it works.

const Toc = styled.ul`
${tw`bg-white fixed hidden lg:flex flex-col rounded p-3 my-3`};
width: 20rem;
left: calc(50% + 400px);
top: 80px;
max-height: 30vh;
`;

const Title = tw.h2`text-2xl mb-2`;

const ToCElement = tw.li`p-1 leading-5 ml-4 mb-4 mr-4 leading-3 list-none`;

const ToCLink = tw.a`hover:text-black transition duration-300 no-underline`;

const InnerScroll = styled.div`
scrollbar-width: thin;
scrollbar-color: #367ee9 rgba(48, 113, 209, 0.3);
overflow: hidden auto;
`;

This is how we can style the scrollbar. The first colour is the colour of the scrollbar and the second colour is the the background colour of the scrollbar.

const InnerScroll = styled.div`
scrollbar-width: thin;
scrollbar-color: #367ee9 rgba(48, 113, 209, 0.3);
overflow: hidden auto;
`;

Note: The styled.div this means InnerScroll when translated to HTML code will be <div>.

<div class="css-91zyin-InnerScroll eqpue8b4">
<li class="css-12965kf-ToCElement eqpue8b2">
<a href="#header-1" class="css-14n9u33-ToCLink eqpue8b3">Header 1</a>
</li>
</div>

Another interesting component to look at is the ToC. This combined twin.macro and emotionjs so width CSS is using emotionjs and we are using twin.macro with {tw...}. Where we fill in the tw with the tailwind styles we want to apply. In the example below, fixed will make the position of the element fixed.

const Toc = styled.ul`
${tw`bg-white fixed hidden lg:flex flex-col rounded p-3 my-3`};
width: 20rem;
left: calc(50% + 400px);
top: 80px;
max-height: 30vh;
`;

Global Style

html {
scroll-behavior: smooth;
// ...
}

This CSS property will stop the scrolling from feeling jerky and instead will be far smoother. So instead of jumping to the header, we click on a header in the ToC and it'll scroll smoothly to that header.

Blog Template

Gatsby Node

exports.createPages = async ({ graphql, actions, reporter }) => {
const { createPage } = actions;

// Define a template for blog post
const blogPost = path.resolve(`./src/templates/blog-post.js`);

// Get all markdown blog posts sorted by date
const result = await graphql(
`
{
allMarkdownRemark(
sort: { fields: [frontmatter___date], order: ASC }
limit: 1000
) {
nodes {
id
fields {
slug
}
}
}
}
`
);

const posts = result.data.allMarkdownRemark.nodes;

// Create blog posts pages
// But only if there's at least one markdown file found at "content/blog" (defined in gatsby-config.js)
// `context` is available in the template as a prop and as a variable in GraphQL

if (posts.length > 0) {
posts.forEach((post, index) => {
createPage({
path: post.fields.slug,
component: blogPost,
});
});
}
};

Gatsby Config

{
resolve: `gatsby-source-filesystem`,
options: {
path: `${__dirname}/content/blog`,
name: `blog`,
},
}

Blog Post

import ToC from "../components/toc";
// ...

const post = data.markdownRemark;
return (
<Layout location={location} title={siteTitle}>
<article className="blog-post" itemScope itemType="http://schema.org/Article">
<ToC headings={post.headings} />
</article>
</Layout>;
)

Let’s also adjust the GraphQL query so we can get the heading data that the ToC component requires. Add the headings field to get the value and the depth fields.

markdownRemark(id: { eq: $id }) {
id
excerpt(pruneLength: 160)
html
headings {
value
depth
}

frontmatter {
title
date(formatString: "MMMM DD, YYYY")
description
}
}

That’s that we added a ToC to our Gatsby Site. We can a bunch of other things to improve it. Such as styling it to make it look better. I also hide my ToC when the width decreases. So you only see a ToC when browsing the site on a Laptop/Desktop.

Appendix

The Startup

Get smarter at building your thing. Join The Startup’s +786K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +786K followers.

Haseeb Majid

Written by

Software Engineer | Pythonista | Typescripter | Docker Advocate | https://haseebmajid.dev

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +786K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store