Feature blog image

MDX autolink headings

4 min read

Sometimes it is useful to create a link to a specific section of an article. So it is common that headings of articles have an id which can be used to create an anchor link.


<h1 id="hello-world">Hello World</h1>

If we want to create a link which scrolls directly to that heading, we can append the following anchor to the url of the article #hello-world.

So much for the theory, but how can we automate this?

Generating ids

Generating ids for each heading is easy with MDX. We can use the rehype-slug plugin, which does the whole work for us.

pnpm
yarn
npm
Copy

pnpm add -D rehype-slug

After the installation we have to tell MDX that it should use the plugin. The example below shows a contentlayer config, but it should work with every MDX setup.

contentlayer.config.mjs
Copy

import rehypeSlug from "rehype-slug";
export default makeSource({
contentDirPath: "content/posts",
documentTypes: [Post],
mdx: {
rehypePlugins: [rehypeSlug],
remarkPlugins: [],
},
});

That's it. Now every heading in our articles should have an id and we could create links for them. But how do we create those links? Do we need to look up the id of the heading from the source code? This sounds too complicated, there must be an easier way.

The simplest way to make it easier to generate links to our headings, is to turn the headings into links which point to themselves. Doing so, we can click them and copy the url from the browser address bar afterwards.

We could use rehype-autolink-headings for this. But if we want to do some styling with TailwindCSS, we have to wrap the heading manually.

To wrap our headings into links, we need to override the default MDX heading components.

components/Markdown.tsx
Copy

const Markdown = ({ code }: Props) => {
const MDXComponent = useMDXComponent(code);
return (
<section className="prose">
<MDXComponent
components={{
h1: heading("h1"),
h2: heading("h2"),
h3: heading("h3"),
h4: heading("h4"),
h5: heading("h5"),
h6: heading("h6"),
}}
/>
</section>
);
};

We use a simple factory function to create a component for each heading:

components/Markdown.tsx
Copy

import { Hash } from "lucide-react";
import { ReactNode } from "react";
type HeadingProps = {
id?: string;
children?: ReactNode;
};
const heading = (As: "h1", `h2`, `h3`, `h4`, `h5`, `h6`) => {
const Heading = ({ id, children }: HeadingProps) => (
<a href={`#${id}`} className="group relative no-underline">
<Hash className="absolute -left-6 hidden group-hover:block p-1 pink-cyan-500 h-full" />
<As id={id}>{children}</As>
</a>
);
Heading.displayName = As;
return Heading;
};

The heading component renders an a tag, with an href pointing to the id which was generated by the rehype-slug and passed as a prop to our component. On the a tag we use the group class which allows us to apply styling to children if the parent is hovered. Also we use the relative class, because we want to position an icon absolute to the left of the heading.

Inside of the a tag we use a Hash icon from the lucide-react package. The icon is positioned absolute with -1.5rem to the left of its normal position (absolute -left-6). The icon is hidden by default, but gets displayed as block if the group is hovered (hidden group-hover:block)

The last child of the a tag is the heading itself which is one of h1, h2, h3, h4, h5 or h6 depending on the parameter with which the factory function was called. The heading renders the id where the href of the a tag points to and the children which is the text of the heading.

And that's it, we can now hover over our MDX headings and should see a hash to the left. If we click a heading, we should see the id appended to the url in the browser address bar.

Posted in: mdx, react, contentlayer, tailwindcss