Generate Blog Heading Anchors in React-Markdown
What Are Heading Anchors?
If you are not already familiar with heading anchors, check out any README.md
on GitHub or hover over a heading within most blog posts and you will likely see a small link icon or hashtag to the left of the title, indicating that you can click to anchor to that specific heading.
If you'd like to link someone to a specific part of a blog post, you can simply click the title you want to send them to and the address bar is automatically populated with an anchor that will link directly to that section.
Why Not Download an Existing Package?
There are excellent rehype and remark plugins such as remark-autolink-headings and rehype-slug. If your goal is to get this feature working as quickly as possible, those are great options.
The issue arises when you do this for every little feature in your blog. If you are loading in 5-10+ plugins into react-markdown
you are going to be shipping enormous javascript bundles to the client.
Extending react-markdown
is super simple and your visitors will feel the difference in first contentful paint times.
Create Markdown.tsx
In this example, I am passing in the raw markdown to our component with the markdown
prop:
1// Markdown.tsx 2import ReactMarkdown from 'react-markdown'; 3 4export default function Markdown({ markdown }) { 5 6 const MarkdownComponents: object = { 7 // Code will go here 8 } 9 10 return ( 11 <ReactMarkdown 12 components={MarkdownComponents} 13 > 14 {markdown.content} 15 </ReactMarkdown> 16 ) 17 18}; 19 20
Create generateSlug.ts
in utils
Folder
We will use this to generate the anchor slug.
1// generateSlug.ts 2 3export const generateSlug = (str: string) => { 4 str = str?.replace(/^\s+|\s+$/g, ''); 5 str = str?.toLowerCase(); 6 const from = 'àáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; 7 const to = 'aaaaaeeeeiiiioooouuuunc------'; 8 9 for (let i = 0, l = from.length; i < l; i++) { 10 str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i)); 11 } 12 13 str = str 14 ?.replace(/[^a-z0-9 -]/g, '') 15 .replace(/\s+/g, '-') 16 .replace(/-+/g, '-'); 17 18 return str; 19}; 20
Import generateSlug.ts
and Customize the Heading Node
In my blog, I am using the H3
heading for blog post headings. Change this to whatever fits your needs:
1// Markdown.tsx 2import ReactMarkdown from 'react-markdown'; 3import { generateSlug } from '@/utils/generateSlug'; 4 5interface H3Props { 6 children: React.ReactNode; 7} 8 9... 10 11h3: (props: H3Props) => { 12 const children = Array.isArray(props.children) 13 ? props.children 14 : [props.children]; 15 const heading = children 16 .flatMap((element) => 17 typeof element === 'string' 18 ? element 19 : element?.type !== undefined && 20 typeof element.props.children === 'string' 21 ? element.props.children 22 : [] 23 ) 24 .join(''); 25 26 const slug = generateSlug(heading); 27 28 return ( 29 <h3 id={slug}> 30 <a href={`#${slug}`} {...props}></a> 31 </h3> 32 ); 33}, 34... 35
Here we are targeting the h3
headings in the markdown and iterating through the returned node arrays via a flatMap
method. The flatMap will look for anything inside the title that isn't a normal string, such as a code
object.
We initially create an empty string assigned to the variable heading
and concatenate any code objects with the strings. Once we have a heading string, we pass it to the generateSlug
function, and finally we tell the ReactMarkdown
component to output an h3 element with an anchor inside of it that contains our newly generated slug.
Dealing with Backticks or Other Special Characters
In the case of this blog, I like to use code
inside of my headings. If you want to do this and you are using react-markdown
, for example, you will need to do some slight modifications to output the correct slug, as strings inside of the backticks are nested within our array as their own object. Here is an example of how you could ensure words in backticks are included in the slug but the backticks themselves are omitted:
1// example alternate logic for backticks which register as code 2// and are nested as their own object inside of the string 3const heading = children 4 .flatMap((element) => 5 typeof element === 'string' 6 ? element 7 : element?.type !== undefined && 8 typeof element.props.children === 'string' 9 ? element.props.children 10 : typeof element === 'object' && element.props?.children?.flatMap 11 ? element.props.children.flatMap((child: object) => { 12 if (typeof child === 'string') return child; 13 return ''; 14 }) 15 : '' 16 ) 17 .join(''); 18
Other special character use-cases could probably be handled inside of the generateSlug.ts
util. An easy way to find what needs to be done is to log the content you are attempting to iterate over with the flatMap.
Improve UX with CSS
You may want to add a scrolling animation to your page when a user clicks an inbound anchor link or clicks on one of the headings. This helps orient the user to the action that has occured (the page is moving to bring an anchor into view). The following CSS should do the trick:
1html { 2 scroll-behavior: smooth; 3} 4
Final Thoughts
You can wrap the markdown component in a class to target with CSS or pass a css prop directly to the ReactMarkdown
component. From there it's up to you to style your link/hashtag icon on hover to indicate your titles are now anchors.