Add a Table of Contents with Smooth scroll using Gatsby and MDX

Scott Spence - Feb 17 '20 - - Dev Community

The main purpose for me documenting this is to demonstrate implementing a table of contents with smooth scroll to the anchors in a Gatsby project using MDX.

In the process I'm also setting up the Gatsby starter with MDX.

TL;DR, go here: Make a TOC component

I like using styled-components for my styling and would like to use them in this example, so I'm going to clone the Gatsby starter I made in a previous post.

Clone the Gatsby Default Starter with styled-components

Spin up a new project using the template I made:

npx gatsby new \
  gatsby-toc-example \
  https://github.com/spences10/gatsby-starter-styled-components
Enter fullscreen mode Exit fullscreen mode

Once that has finished installing I'm going to cd into the project (cd gatsby-toc-example) and install dependencies for using MDX in Gatsby.

# you can use npm if you like
yarn add gatsby-plugin-mdx \
  @mdx-js/mdx \
  @mdx-js/react
Enter fullscreen mode Exit fullscreen mode

Add some content

Create a posts directory with a toc-example directory in it which contains the index.mdx file I'll be adding the content to.

mkdir -p posts/toc-example
touch posts/toc-example/index.mdx
Enter fullscreen mode Exit fullscreen mode

I'll paste in some content, I'll take from the markdown from this post!

Configure the project to use MDX

To enable MDX in the project I'll add the gatsby-plugin-mdx configuration to the gatsby-config.js file.

{
  resolve: `gatsby-plugin-mdx`,
  options: {
    extensions: [`.mdx`, `.md`],
    gatsbyRemarkPlugins: [],
  },
},
Enter fullscreen mode Exit fullscreen mode

I'll also need to add the posts directory to the gatsby-source-filesystem config as well.

{
  resolve: `gatsby-source-filesystem`,
  options: {
    name: `posts`,
    path: `${__dirname}/posts`,
  },
},
Enter fullscreen mode Exit fullscreen mode

Stop the dev server (Ctrl+c in the terminal) and start with the new configuration.

Once the dev server has started back up, I'll validate the Gatsby MDX config by seeing if allMdx is available in the GraphiQL explorer (localhost:8000/___graphql).

{
  allMdx {
    nodes {
      excerpt
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Configure Gatsby node to create the fields and pages

Here I'll make all the paths for the files in the posts directory, currently it's only gatsby-toc-example. I'll do that with createFilePath when creating the node fields with createNodeField.

const { createFilePath } = require(`gatsby-source-filesystem`);

exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions;
  if (node.internal.type === `Mdx`) {
    const value = createFilePath({ node, getNode });
    createNodeField({
      name: `slug`,
      node,
      value,
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Stop and start the gatsby dev server again as I changed gatsby-node.js.

In the Gatsby GraphQL explorer (GraphiQL) validate that the fields are being created.

{
  allMdx {
    nodes {
      fields {
        slug
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a post template

To make the pages for the content in the posts directory, I'll need a template to use with the Gatsby createPages API.

To do that, I'll create a templates directory in src then make a post-template.js file.

mkdir src/templates
touch src/templates/post-template.js
Enter fullscreen mode Exit fullscreen mode

For now, I'm going to return a h1 with Hello template so I can validate the page was created by Gatsby node.

import React from 'react';

export default () => {
  return (
    <>
      <h1>Hello template</h1>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Save the template, now to create the pages in gatsby-node.js I'm adding the following.

Lines {2,4-35}
const { createFilePath } = require(`gatsby-source-filesystem`);
const path = require(`path`);

exports.createPages = ({ actions, graphql }) => {
  const { createPage } = actions;
  const postTemplate = path.resolve('src/templates/post-template.js');

  return graphql(`
    {
      allMdx(sort: { fields: [frontmatter___date], order: DESC }) {
        nodes {
          fields {
            slug
          }
        }
      }
    }
  `).then(result => {
    if (result.errors) {
      throw result.errors;
    }

    const posts = result.data.allMdx.nodes;

    posts.forEach((post, index) => {
      createPage({
        path: post.fields.slug,
        component: postTemplate,
        context: {
          slug: post.fields.slug,
        },
      });
    });
  });
};

exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions;
  if (node.internal.type === `Mdx`) {
    const value = createFilePath({ node, getNode });
    createNodeField({
      name: `slug`,
      node,
      value,
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

I know there's a lot in there to unpack, so, if you need more detail check out the sections in the "Build a coding blog from scratch with Gatsby and MDX", listed here:

Confirm the pages were created with Gatsby's built in 404 page

Stop and start the dev server as there's been changes to Gatsby node.

Check the page has been created, to do that add /404.js to the dev server url which will show all the available pages in the project.

From here I can select the path created to /toc-example/ and confirm the page was created.

Build out the post template to use the MDXRenderer

Now I can add the data to the post-template.js page from a GraphQL query. I'll do that with the Gatsby graphql tag and query some frontmatter, body and the table of contents.

This query is taking the String! parameter of slug passed to it from createPage in gatsby-node.js.

query PostBySlug($slug: String!) {
  mdx(fields: { slug: { eq: $slug } }) {
    frontmatter {
      title
      date(formatString: "YYYY MMMM Do")
    }
    body
    excerpt
    tableOfContents
    timeToRead
    fields {
      slug
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Destructure the body and frontmatter data from data.mdx, data is the results of the PostBySlug query. Wrap the body data in the <MDXRenderer> component.

The frontmatter.title and frontmatter.date can be used in h1 and p tags for now.

Lines {1-2,5-6,9-10,16-32}
import { graphql } from 'gatsby';
import { MDXRenderer } from 'gatsby-plugin-mdx';
import React from 'react';

export default ({ data }) => {
  const { body, frontmatter } = data.mdx;
  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.date}</p>
      <MDXRenderer>{body}</MDXRenderer>
    </>
  );
};

export const query = graphql`
  query PostBySlug($slug: String!) {
    mdx(fields: { slug: { eq: $slug } }) {
      frontmatter {
        title
        date(formatString: "YYYY MMMM Do")
      }
      body
      excerpt
      tableOfContents
      timeToRead
      fields {
        slug
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

I'm going to be using tableOfContents later when I make a table of contents component.

Add page elements for the MDXProvider

The content (headings, paragraphs, etc.) were reset with styled-reset in the template being used so will need to be added in.

I'm going to be amending the already existing H1 and <P> styled-components to be React components so that I can spread in the props I need for the heading ID.

Lines {1,4,11-13}
import React from 'react';
import styled from 'styled-components';

export const StyledH1 = styled.h1`
  font-size: ${({ theme }) => theme.fontSize['4xl']};
  font-family: ${({ theme }) => theme.font.serif};
  margin-top: ${({ theme }) => theme.spacing[8]};
  line-height: ${({ theme }) => theme.lineHeight.none};
`;

export const H1 = props => {
  return <StyledH1 {...props}>{props.children}</StyledH1>;
};
Enter fullscreen mode Exit fullscreen mode

Create a <H2> component based off of the <H1>, adjust the spacing and font size.

import React from 'react';
import styled from 'styled-components';

export const StyledH2 = styled.h2`
  font-size: ${({ theme }) => theme.fontSize['3xl']};
  font-family: ${({ theme }) => theme.font.serif};
  margin-top: ${({ theme }) => theme.spacing[6]};
  line-height: ${({ theme }) => theme.lineHeight.none};
`;

export const H2 = props => {
  return <StyledH2 {...props}>{props.children}</StyledH2>;
};
Enter fullscreen mode Exit fullscreen mode

I'll need to add the newly created H2 to the index file for page-elements:

Line {2}
export * from './h1';
export * from './h2';
export * from './p';
Enter fullscreen mode Exit fullscreen mode

Same with the <P> as I did with the H1, I'll switch it to use React.

import React from 'react';
import styled from 'styled-components';

export const StyledP = styled.p`
  margin-top: ${({ theme }) => theme.spacing[3]};
  strong {
    font-weight: bold;
  }
  em {
    font-style: italic;
  }
`;

export const P = props => {
  const { children, ...rest } = props;
  return <StyledP {...rest}>{children}</StyledP>;
};
Enter fullscreen mode Exit fullscreen mode

Importing the modified components into the root-wrapper.js I can now pass them into the <MDXProvider> which is used to map to the HTML elements created in markdown.

There's a complete listing of all the HTML elements that can be customised on the MDX table of components.

In this example I'm mapping the H1, H2 and P components to the corresponding HTML elements and passing them into the <MDXProvider>.

Lines {1,5,8-12,17,19}
import { MDXProvider } from '@mdx-js/react';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import Layout from './src/components/layout';
import { H1, H2, P } from './src/components/page-elements';
import { GlobalStyle, theme } from './src/theme/global-style';

const components = {
  h1: props => <H1 {...props} />,
  h2: props => <H2 {...props} />,
  p: props => <P {...props} />,
};

export const wrapRootElement = ({ element }) => (
  <ThemeProvider theme={theme}>
    <GlobalStyle />
    <MDXProvider components={components}>
      <Layout>{element}</Layout>
    </MDXProvider>
  </ThemeProvider>
);
Enter fullscreen mode Exit fullscreen mode

Add gatsby-remark-autolink-headers for adding id's to headers

Now I have a page, with some content and headers I should now be able to navigate to the individual headings, right?

Well, not quite, although the headers are there, there's no IDs in them to scroll to yet.

I can use gatsby-remark-autolink-headers to create the heading IDs.

yarn add gatsby-remark-autolink-headers
Enter fullscreen mode Exit fullscreen mode

Add gatsby-remark-autolink-headers in the Gatsby MDX config.

Line {5}
{
  resolve: `gatsby-plugin-mdx`,
  options: {
    extensions: [`.mdx`, `.md`],
    gatsbyRemarkPlugins: [`gatsby-remark-autolink-headers`],
  },
},
Enter fullscreen mode Exit fullscreen mode

As I've changed the gatsby-config.js file I'll need to stop and start the dev server.

Fix the weird positioning on the SVGs for the links added by gatsby-remark-autolink-headers.

Do that by making some reusable CSS with a tagged template literal, I'll put it in it's own file heading-link.js.

touch src/components/page-elements/heading-link.js
Enter fullscreen mode Exit fullscreen mode

Then add the CSS in as a template literal:

export const AutoLink = `
  a {
    float: left;
    padding-right: 4px;
    margin-left: -20px;
  }
  svg {
    visibility: hidden;
  }
  &:hover {
    a {
      svg {
        visibility: visible;
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Then I'm going to use that (AutoLink) in the H2 and anywhere else that could have a link applied to it (any heading element).

Line {10}
import React from 'react';
import styled from 'styled-components';
import { AutoLink } from './linked-headers';

export const StyledH2 = styled.h2`
  font-size: ${({ theme }) => theme.fontSize['3xl']};
  font-family: ${({ theme }) => theme.font.serif};
  margin-top: ${({ theme }) => theme.spacing[6]};
  line-height: ${({ theme }) => theme.lineHeight.none};
  ${AutoLink}
`;

export const H2 = props => {
  return <StyledH2 {...props}>{props.children}</StyledH2>;
};
Enter fullscreen mode Exit fullscreen mode

Clicking around on the links now should scroll to each one smoothly and have the SVG for the link only visible on hover.

Make a TOC component

From here onwards is what the whole post boils down to! I did want to go through the process of how you would do something similar yourself though, so I'm hoping this has helped in some way.

For the TOC with smooth scroll you need several things:

  • scroll-behavior: smooth; added to your html, this is part of the starter I made in a previous post.

  • IDs in the headings to scroll to, this is done with gatsby-remark-autolink-headers.

  • A table of contents which is provided by Gatsby MDX with tableOfContents.

The first two parts have been covered so now to create a TOC component, with styled-components.

In the post-template.js I'll create a Toc component for some positioning and create a scrollable div to use inside of that.

const Toc = styled.ul`
  position: fixed;
  left: calc(50% + 400px);
  top: 110px;
  max-height: 70vh;
  width: 310px;
  display: flex;
  li {
    line-height: ${({ theme }) => theme.lineHeight.tight};
    margin-top: ${({ theme }) => theme.spacing[3]};
  }
`;

const InnerScroll = styled.div`
  overflow: hidden;
  overflow-y: scroll;
`;
Enter fullscreen mode Exit fullscreen mode

The main content is overlapping with the TOC here so I'm going to add a maxWidth inline on the layout.js component.

<main style={{ maxWidth: '640px' }}>{children}</main>
Enter fullscreen mode Exit fullscreen mode

Conditionally render the TOC

Time to map over the tableOfContents object:

{
  typeof tableOfContents.items === 'undefined' ? null : (
    <Toc>
      <InnerScroll>
        <H2>Table of contents</H2>
        {tableOfContents.items.map(i => (
          <li key={i.url}>
            <a href={i.url} key={i.url}>
              {i.title}
            </a>
          </li>
        ))}
      </InnerScroll>
    </Toc>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here's the full post-template.js file, I've reused the page-elements components for the h1, h2 on the TOC and p:

Lines {4-5,7-18,20-23,26,29-44}
import { graphql } from 'gatsby';
import { MDXRenderer } from 'gatsby-plugin-mdx';
import React from 'react';
import styled from 'styled-components';
import { H1, H2, P } from '../components/page-elements';

const Toc = styled.ul`
  position: fixed;
  left: calc(50% + 400px);
  top: 110px;
  max-height: 70vh;
  width: 310px;
  display: flex;
  li {
    line-height: ${({ theme }) => theme.lineHeight.tight};
    margin-top: ${({ theme }) => theme.spacing[3]};
  }
`;

const InnerScroll = styled.div`
  overflow: hidden;
  overflow-y: scroll;
`;

export default ({ data }) => {
  const { body, frontmatter, tableOfContents } = data.mdx;
  return (
    <>
      <H1>{frontmatter.title}</H1>
      <P>{frontmatter.date}</P>
      {typeof tableOfContents.items === 'undefined' ? null : (
        <Toc>
          <InnerScroll>
            <H2>Table of contents</H2>
            {tableOfContents.items.map(i => (
              <li key={i.url}>
                <a href={i.url} key={i.url}>
                  {i.title}
                </a>
              </li>
            ))}
          </InnerScroll>
        </Toc>
      )}
      <MDXRenderer>{body}</MDXRenderer>
    </>
  );
};

export const query = graphql`
  query PostBySlug($slug: String!) {
    mdx(fields: { slug: { eq: $slug } }) {
      frontmatter {
        title
        date(formatString: "YYYY MMMM Do")
      }
      body
      excerpt
      tableOfContents
      timeToRead
      fields {
        slug
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

That's it, I can play around navigating between headings now from the TOC.

📺 Here's a video detailing the process.

Resources that helped me

Thanks for reading 🙏

Please take a look at my other content if you enjoyed this.

Follow me on Twitter or Ask Me Anything on GitHub.


. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .