Adding anchor links to headings in Astro
I like the ability to link to the most relevant section of a piece of text when I’m sharing it with someone. A plain link with a “scroll to the third paragraph of the second section” comment always feels unwieldy. We have anchor links, so I decided to add them to my Astro site. Hover over one of the headings in this post to see the final outcome. Here’s how I did it.
1. Install rehype
plugins
pnpm add rehype-slug rehype-autolink-headings
It turns out that Astro uses tools called remark
and rehype
to convert your markdown files into webpages. remark
converts the plain markdown into an AST (abstract syntax tree) and then rehype
converts it to HTML which can be rendered by the browser. Both of them support customisation via plugins which allow control over rendering your markdown.
rehype-slug
will automatically turn a heading into a url-friendly slug and add this as an id
on the heading element. e.g.
# My Heading
becomes
<h1 id="my-heading">My Heading</h1>
This can then be used by the second plugin, rehype-autolink-headings
which does what it says on the tin. The id
attribute on the heading becomes the anchor link for that section.
2. Update astro.config.mjs
to activate the plugins
Unfortunately Astro doesn’t know about our new rehype
plugins automagically so we need to specify their usage in the configuration file astro.config.mjs
like so:
// @ts-check
import { defineConfig } from "astro/config";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSlug from "rehype-slug";
// https://astro.build/config
export default defineConfig({
// ...
markdown: {
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
},
});
3. Customise
By default, rehype-autolink-headings
will prepend a link element before the heading. Personally, I wanted mine appended and I wanted a nice SVG element which only became visible on hover. To achieve this I did the following:
Update the plugin behaviour to append a custom SVG by adding configuration options in the astro.config.mjs
file.
NOTE
To add configuration options for a plugin you need to replace the plugin with an array where the first element is the plugin and the second is the options object. e.g. rehypeAutolinkHeadings
-> [rehypeAutolinkHeadings, { ...options }]
Here’s my full updated astro.config.mjs
:
// @ts-check
import { defineConfig } from "astro/config";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSlug from "rehype-slug";
// https://astro.build/config
export default defineConfig({
// ...
markdown: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: ["anchor-link"],
ariaLabel: "Link to section",
},
content: () => {
return [
{
type: "element",
tagName: "svg",
properties: {
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
className: ["link-icon"],
},
children: [
{
type: "element",
tagName: "g",
properties: {
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
},
},
{
type: "element",
tagName: "path",
properties: {
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
},
},
],
},
],
},
];
},
},
],
],
},
});
This now outputs my links in this format (svg
pruned for clarity):
<h2 id="example">
Example
<a class="anchor-link" aria-label="Link to section" href="#example">
<svg ... class="link-icon">
<g>
<path></path>
<path></path>
</g>
</svg>
</a>
</h2>
We can add the following CSS to achieve the styling that we’re looking for
h1,
h2,
h3,
h4,
h5,
h6 {
display: flex;
align-items: center;
color: var(--primary);
gap: 0.5rem; /* Offset our SVG from the heading a little */
&:hover {
& .anchor-link {
visibility: visible;
opacity: 1;
}
}
}
.anchor-link {
display: inline-flex;
align-items: center;
text-decoration: none; /* Remove underline and automatic browser styling */
visibility: hidden; /* Uninteractable when not hovered */
opacity: 0; /* Start from 0 for the fade-in transition */
transition: opacity 0.2s ease-in-out;
height: 1em; /* Ensure we match the height of the heading */
}
.link-icon {
display: block;
width: 0.9em;
height: 0.9em;
}
And that’s it!
There’s some further improvements that could be made. Currently the SVG being written out in hast
(HTML abstract syntax tree) format feels quite unwieldy and it might be nicer to have it store as a normal .svg
and somehow imported but I didn’t get that far. If you know of a fast way to achieve that, please let me know.