Add a Table of Contents with Smooth scroll using Gatsby and MDX
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:
1npx gatsby new \2 gatsby-toc-example \3 https://github.com/spences10/gatsby-starter-styled-components
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.
1# you can use npm if you like2yarn add gatsby-plugin-mdx \3 @mdx-js/mdx \4 @mdx-js/react
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.
1mkdir -p posts/toc-example2touch posts/toc-example/index.mdx
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.
1{2 resolve: `gatsby-plugin-mdx`,3 options: {4 extensions: [`.mdx`, `.md`],5 gatsbyRemarkPlugins: [],6 },7},
I'll also need to add the posts directory to the
gatsby-source-filesystem
config as well.
1{2 resolve: `gatsby-source-filesystem`,3 options: {4 name: `posts`,5 path: `${__dirname}/posts`,6 },7},
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
).
1{2 allMdx {3 nodes {4 excerpt5 }6 }7}
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
.
1const { createFilePath } = require(`gatsby-source-filesystem`)23exports.onCreateNode = ({ node, actions, getNode }) => {4 const { createNodeField } = actions5 if (node.internal.type === `Mdx`) {6 const value = createFilePath({ node, getNode })7 createNodeField({8 name: `slug`,9 node,10 value,11 })12 }13}
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.
1{2 allMdx {3 nodes {4 fields {5 slug6 }7 }8 }9}
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.
1mkdir src/templates2touch src/templates/post-template.js
For now, I'm going to return a h1
with Hello template so I can
validate the page was created by Gatsby node.
1import React from 'react'23export default () => {4 return (5 <>6 <h1>Hello template</h1>7 </>8 )9}
Save the template, now to create the pages in gatsby-node.js
I'm
adding the following.
1const { createFilePath } = require(`gatsby-source-filesystem`)2const path = require(`path`)34exports.createPages = ({ actions, graphql }) => {5 const { createPage } = actions6 const postTemplate = path.resolve('src/templates/post-template.js')78 return graphql(`9 {10 allMdx(sort: { fields: [frontmatter___date], order: DESC }) {11 nodes {12 fields {13 slug14 }15 }16 }17 }18 `).then(result => {19 if (result.errors) {20 throw result.errors21 }2223 const posts = result.data.allMdx.nodes2425 posts.forEach((post, index) => {26 createPage({27 path: post.fields.slug,28 component: postTemplate,29 context: {30 slug: post.fields.slug,31 },32 })33 })34 })35}3637exports.onCreateNode = ({ node, actions, getNode }) => {38 const { createNodeField } = actions39 if (node.internal.type === `Mdx`) {40 const value = createFilePath({ node, getNode })41 createNodeField({42 name: `slug`,43 node,44 value,45 })46 }47}
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
.
1query PostBySlug($slug: String!) {2 mdx(fields: { slug: { eq: $slug } }) {3 frontmatter {4 title5 date(formatString: "YYYY MMMM Do")6 }7 body8 excerpt9 tableOfContents10 timeToRead11 fields {12 slug13 }14 }15}
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.
1import { graphql } from 'gatsby'2import { MDXRenderer } from 'gatsby-plugin-mdx'3import React from 'react'45export default ({ data }) => {6 const { body, frontmatter } = data.mdx7 return (8 <>9 <h1>{frontmatter.title}</h1>10 <p>{frontmatter.date}</p>11 <MDXRenderer>{body}</MDXRenderer>12 </>13 )14}1516export const query = graphql`17 query PostBySlug($slug: String!) {18 mdx(fields: { slug: { eq: $slug } }) {19 frontmatter {20 title21 date(formatString: "YYYY MMMM Do")22 }23 body24 excerpt25 tableOfContents26 timeToRead27 fields {28 slug29 }30 }31 }32`
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.
1import React from 'react'2import styled from 'styled-components'34export const StyledH1 = styled.h1`5 font-size: ${({ theme }) => theme.fontSize['4xl']};6 font-family: ${({ theme }) => theme.font.serif};7 margin-top: ${({ theme }) => theme.spacing[8]};8 line-height: ${({ theme }) => theme.lineHeight.none};9`1011export const H1 = props => {12 return <StyledH1 {...props}>{props.children}</StyledH1>13}
Create a <H2>
component based off of the <H1>
, adjust the spacing
and font size.
1import React from 'react'2import styled from 'styled-components'34export const StyledH2 = styled.h2`5 font-size: ${({ theme }) => theme.fontSize['3xl']};6 font-family: ${({ theme }) => theme.font.serif};7 margin-top: ${({ theme }) => theme.spacing[6]};8 line-height: ${({ theme }) => theme.lineHeight.none};9`1011export const H2 = props => {12 return <StyledH2 {...props}>{props.children}</StyledH2>13}
I'll need to add the newly created H2
to the index file for
page-elements
:
1export * from './h1'2export * from './h2'3export * from './p'
Same with the <P>
as I did with the H1
, I'll switch it to use
React.
1import React from 'react'2import styled from 'styled-components'34export const StyledP = styled.p`5 margin-top: ${({ theme }) => theme.spacing[3]};6 strong {7 font-weight: bold;8 }9 em {10 font-style: italic;11 }12`1314export const P = props => {15 const { children, ...rest } = props16 return <StyledP {...rest}>{children}</StyledP>17}
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>
.
1import { MDXProvider } from '@mdx-js/react'2import React from 'react'3import { ThemeProvider } from 'styled-components'4import Layout from './src/components/layout'5import { H1, H2, P } from './src/components/page-elements'6import { GlobalStyle, theme } from './src/theme/global-style'78const components = {9 h1: props => <H1 {...props} />,10 h2: props => <H2 {...props} />,11 p: props => <P {...props} />,12}1314export const wrapRootElement = ({ element }) => (15 <ThemeProvider theme={theme}>16 <GlobalStyle />17 <MDXProvider components={components}>18 <Layout>{element}</Layout>19 </MDXProvider>20 </ThemeProvider>21)
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.
1yarn add gatsby-remark-autolink-headers
Add gatsby-remark-autolink-headers
in the Gatsby MDX config.
1{2 resolve: `gatsby-plugin-mdx`,3 options: {4 extensions: [`.mdx`, `.md`],5 gatsbyRemarkPlugins: [`gatsby-remark-autolink-headers`],6 },7},
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
.
1touch src/components/page-elements/heading-link.js
Then add the CSS in as a template literal:
1export const AutoLink = `2 a {3 float: left;4 padding-right: 4px;5 margin-left: -20px;6 }7 svg {8 visibility: hidden;9 }10 &:hover {11 a {12 svg {13 visibility: visible;14 }15 }16 }17`
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).
1import React from 'react'2import styled from 'styled-components'3import { AutoLink } from './linked-headers'45export const StyledH2 = styled.h2`6 font-size: ${({ theme }) => theme.fontSize['3xl']};7 font-family: ${({ theme }) => theme.font.serif};8 margin-top: ${({ theme }) => theme.spacing[6]};9 line-height: ${({ theme }) => theme.lineHeight.none};10 ${AutoLink}11`1213export const H2 = props => {14 return <StyledH2 {...props}>{props.children}</StyledH2>15}
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 yourhtml
, 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.
1const Toc = styled.ul`2 position: fixed;3 left: calc(50% + 400px);4 top: 110px;5 max-height: 70vh;6 width: 310px;7 display: flex;8 li {9 line-height: ${({ theme }) => theme.lineHeight.tight};10 margin-top: ${({ theme }) => theme.spacing[3]};11 }12`1314const InnerScroll = styled.div`15 overflow: hidden;16 overflow-y: scroll;17`
The main
content is overlapping with the TOC here so I'm going to
add a maxWidth
inline on the layout.js
component.
1<main style={{ maxWidth: '640px' }}>{children}</main>
Conditionally render the TOC
Time to map over the tableOfContents
object:
1{2 typeof tableOfContents.items === 'undefined' ? null : (3 <Toc>4 <InnerScroll>5 <H2>Table of contents</H2>6 {tableOfContents.items.map(i => (7 <li key={i.url}>8 <a href={i.url} key={i.url}>9 {i.title}10 </a>11 </li>12 ))}13 </InnerScroll>14 </Toc>15 )16}
Here's the full post-template.js
file, I've reused the
page-elements
components for the h1
, h2
on the TOC and p
:
1import { graphql } from 'gatsby'2import { MDXRenderer } from 'gatsby-plugin-mdx'3import React from 'react'4import styled from 'styled-components'5import { H1, H2, P } from '../components/page-elements'67const Toc = styled.ul`8 position: fixed;9 left: calc(50% + 400px);10 top: 110px;11 max-height: 70vh;12 width: 310px;13 display: flex;14 li {15 line-height: ${({ theme }) => theme.lineHeight.tight};16 margin-top: ${({ theme }) => theme.spacing[3]};17 }18`1920const InnerScroll = styled.div`21 overflow: hidden;22 overflow-y: scroll;23`2425export default ({ data }) => {26 const { body, frontmatter, tableOfContents } = data.mdx27 return (28 <>29 <H1>{frontmatter.title}</H1>30 <P>{frontmatter.date}</P>31 {typeof tableOfContents.items === 'undefined' ? null : (32 <Toc>33 <InnerScroll>34 <H2>Table of contents</H2>35 {tableOfContents.items.map(i => (36 <li key={i.url}>37 <a href={i.url} key={i.url}>38 {i.title}39 </a>40 </li>41 ))}42 </InnerScroll>43 </Toc>44 )}45 <MDXRenderer>{body}</MDXRenderer>46 </>47 )48}4950export const query = graphql`51 query PostBySlug($slug: String!) {52 mdx(fields: { slug: { eq: $slug } }) {53 frontmatter {54 title55 date(formatString: "YYYY MMMM Do")56 }57 body58 excerpt59 tableOfContents60 timeToRead61 fields {62 slug63 }64 }65 }66`
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.