← Back to Blog

Section Header Anchors in Astro

Published November 22, 2023

If you spend much time on the web reading blogs or documentation, you may have encountered a little link icon next to a header. If you click on it, it will “anchor” to that section, meaning it will scroll to it and update the URL. It’s a great way to share a specific section of a page with someone else. For example, maybe you’re reading up on Astro components, and you want to link a coworker specifically to the section on “Slots.” Astro gives you the ability to do that! Just click on the link icon, copy the updated URL, and share.

In order to accomplish this in your own project, you need two things:

  1. An element needs to have a unique ID, typically a header
  2. You need an element for the user to click

In this post, I will walk you through how to set it up in an Astro project that uses markdown for content. Although the exact steps are specific to Astro with markdown, the packages are not. You can use them pretty much anywhere that uses JavaScript.

#Examples Across the Web

I mentioned Astro as an example, but there are lots of places that utilize this pattern. Here are just a few examples I found around the web.

screenshot of part of the Astro documentation
Astro documentation includes a link icon to the left of the section title.
screenshot of a section of a CSS Tricks blog post
CSS Tricks includes a link icon to the left of the section title.
screenshot of a section of a Smashing Magazine blog post
Smashing Magazine includes a hash symbol to the right of the section title.

#Generate Unique IDs

In order to have an anchor on your page, you need to have a unique ID. You could manually include this throughout your content by including it in the code, like <h3 id="unique-id">Unique ID</h3>, but we wouldn’t be developers if we wanted to do things manually. Besides, we’re using markdown for our code, and although you can use HTML in markdown, that defeats the purpose. Thankfully there is a quick and painless way to add these IDs programmatically: rehype-slug.

  1. npm install rehype-slug
  2. Configure the package in astro.config.mjs
    1. Import the package (import rehypeSlug from 'rehype-slug')
    2. Add to the configuration:
      export default defineConfig({
        ...
        markdown: {
          rehypePlugins: [
            rehypeSlug,
          ],
        },
        ...
      });
  3. Celebrate how easy that was.

The plugin automatically gives each heading element a unique ID based on the text in the element. It even still makes sure the IDs are unique, even if you have two headings that have the same text.

Now that each heading has a unique ID, we can start building out the links. For this step, we’ll use the rehype-autolink-headings package. As the previous package automatically added a unique ID to each header, this package automatically wraps each header with a link (wraps is the default behavior).

  1. npm install rehype-autolink-headings
  2. Configure the package in astro.config.mjs
    1. Import the package (import rehypeAutolinkHeadings from 'rehype-autolink-headings')
    2. Add to the configuration:
      export default defineConfig({
        ...
        markdown: {
          rehypePlugins: [
            rehypeSlug,
            [
              rehypeAutolinkHeadings,
              {
                behavior: 'prepend',
                content: {
                  type: 'text',
                  value: '#',
                },
                headingProperties: {
                  className: ['anchor'],
                },
                properties: {
                  className: ['anchor-link'],
                },
              },
            ],
          ],
        },
        ...
      });

These are the settings I’m using on my blog, but there is a ton you can customize. You can check out the documentation for their options, but let’s go over them here too, because it isn’t obvious how it translate to use in Astro.

There is a ton of customization available in rehype-autolink-headings. The docs are good, but they don’t necessarily translate to Astro one-to-one, so let’s go over them a bit.

#Behavior

There are several types of behavior that this package supports:

  1. prepend (default) inserts the link before the heading text.
    <h1 id="unique">Title</h1>
    <!-- becomes... -->
    <h1 id="unique">
      <a href="#unique">#</a>
      Title
    </h1>
  2. append inserts the link after the heading text.
    <h1 id="unique">Title</h1>
    <!-- becomes... -->
    <h1 id="unique">
      Title
      <a href="#unique">#</a>
    </h1>
  3. wrap wraps the heading text.
    <h1 id="unique">Title</h1>
    <!-- becomes... -->
    <h1 id="unique">
      <a href="#unique">
        Title
      </a>
    </h1>
  4. before inserts the link before the heading tag.
    <h1 id="unique">Title</h1>
    <!-- becomes... -->
    <a href="#unique">#</a>
    <h1 id="unique">Title</h1>
  5. after inserts the link after the heading tag.
    <h1 id="unique">Title</h1>
    <!-- becomes... -->
    <h1 id="unique">Title</h1>
    <a href="#unique">#</a>

No option here is better or worse than another. What works for you will depend on what you are trying to accomplish. Although note that if you use wrap and a content option, the package will concatenate the content to the end of the heading text (Title#, for example).

#Content

This setting defines what is going to be inside the link itself. For now, I am just using a text with the value #, like this:

content: {
  type: 'text',
  value: '#',
}

But if you want to have it be something like [jump to section], you could change it to the following:

content: {
  type: 'text',
  value: `[jump to section]`,
}

You can even specify an HTML element, like if you want to use an icon from Font Awesome or other icon library. Just replace type text with type raw as seen here:

content: {
  type: 'raw',
  value: '<i class="fa-solid fa-link"></i>',
},

If you do not include this field, it defaults to <span class="icon icon-link"></span>.

#Properties

The properties option allows you to define attributes on the element that the plugin is adding to the markup and the heading is being manipulated. For example, if you want a class name, or a data attribute, or something for accessibility, this setting is the place to do it.

This blog is using both headingProperties and regular properties options. The headingProperties modifies the heading element that is being used as the anchor, while properties modifies the added a element. Without either, for example, this is the markup that gets added to our DOM (assuming behavier is prepend):

<h1 id="unique">
  <a href="#unique">#</a>
  Title
</h1>

But when we add the following properties options to our astro.config.mjs, the markup changes to include our custom class names.

# astro.config.mjs
headingProperties: {
  className: ['anchor'],
},
properties: {
  className: ['anchor-link'],
},

# Updated HTML
<h1 id="unique" class="anchor">
  <a href="#unique" class="anchor-link">#</a>
  Title
</h1>

This works for other properties in the same way, like dataUrl: 'localhost' would be added to the element as data-url="localhost".

#A Note on Rehype

Throughout this process we relied on two packages to do our heavy lifting, rehype-slug and rehype-autolink-headings. Both are plugins for rehype, which is a project that transforms content — specifically HTML — using ASTs or abstract syntax trees. Delving deeper into this topic is well beyond the scope of this blog post, but you can learn more about the core project at unifiedjs.com.