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.