Next-level Heading Anchors with Astro and rehype

Next-level Heading Anchors with Astro and rehype

·
Last updated on
· 4 min read
A guide on next-level heading anchors with Astro's Markdown pages and rehype.

Introduction

Astro enables powerful customization of Markdown pages by using rehype plugins. In this guide, I’ll teach you how to automatically create next-level anchor links for headings with Astro and Tailwind CSS. In the following sections, I’ll assume basic knowledge about the used technologies.

If you have already worked with heading anchors on a site that has a sticky header (like this one), you will know that navigating to an anchor by its slug (e.g., #introduction) will result in the heading being obstructed by the header. By following the guide below, you will end up with anchor links that avoid this issue.

Configuring rehype

To get started, add the @astrojs/mdx and @astrojs/tailwind integrations to your project. We will also need the following packages:

yarn add -D rehype-autolink-headings hastscript hast-util-to-string

Then, we will update Astro’s config astro.config.mjs to automatically generate the links. I have taken quite a bit of this configuration from the official Astro doc’s config, including their AnchorLinkIcon and createSROnlyLabel. However, I couldn’t get the rehype plugins working unless I configured them in the config of the @astrojs/mdx integration.

import mdx from '@astrojs/mdx'
import tailwind from '@astrojs/tailwind'
import { defineConfig } from 'astro/config'
import { toString } from 'hast-util-to-string'
import { h } from 'hastscript'
import autolinkHeadings from 'rehype-autolink-headings'

// The following configuration for rehype-autolink-headings was taken from https://github.com/withastro/docs/blob/main/astro.config.ts
const AnchorLinkIcon = h(
  'svg',
  {
    width: 16,
    height: 16,
    version: 1.1,
    viewBox: '0 0 16 16',
    xlmns: 'http://www.w3.org/2000/svg',
  },
  h('path', {
    fillRule: 'evenodd',
    fill: 'currentcolor',
    d: 'M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z',
  })
)

const createSROnlyLabel = (text) => {
  const node = h('span.sr-only', `Section titled ${escape(text)}`)
  node.properties['is:raw'] = true
  return node
}

export default defineConfig({
  integrations: [
    mdx({
      rehypePlugins: {
        extends: [
          [
            autolinkHeadings,
            {
              behavior: 'append',
              group: ({ tagName }) =>
                h(`div.heading-wrapper.level-${tagName}`, {
                  tabIndex: -1,
                }),
              content: (heading) => [
                h(
                  `span.anchor-icon`,
                  {
                    ariaHidden: 'true',
                  },
                  AnchorLinkIcon
                ),
                createSROnlyLabel(toString(heading)),
              ],
            },
          ],
        ],
      },
    }),
    tailwind(),
    // ...remaining integrations
  ],
  // ...remaining config
})

Now all of your headings in .mdx files should have anchors and IDs! The labels for screen readers are already hidden by Tailwind’s styles for the .sr-only class we applied.

Unfortunately, icons will not be aligned with their headings, and navigating to a heading via its slug will result in it being obstructed by the header. We will fix that in the next section!

Issues and Workarounds

Here’s what we need to achieve:

  1. Align the anchor icon with the heading’s text
  2. Prevent a sticky header from obstructing headings when navigating by slugs

Anchor Icon Alignment

Fixing the alignment of anchor icons is trivial using Flexbox. An intuitive solution for the problem are the following Tailwind utilities:

@layer components {
  *:is(h1, h2, h3, h4, h5, h6) {
    @apply flex items-center gap-2;
  }
}

Heading Offset

Next, we will prevent a sticky header from overlaying headings by using the scroll-margin-top property. In addition to the height of the header, you may also consider adding any padding of the Markdown’s container element to the scroll-margin. In the case of my blog, this is 48px (height of the header) + 32px (padding of main) = 80px. This translates to Tailwind’s scroll-mt-20 utility.

@layer components {
  *:is(h1, h2, h3, h4, h5, h6) {
    @apply scroll-mt-20;
  }
}

Conclusion

Setting up automatic anchor generation is not that difficult, thanks to rehype-autolink-headers. While they require some styling to work well with sticky headers, Tailwind CSS reduces this to a few lines of code.

We ended up with the following CSS to create accessible anchors and headings that won’t be obstructed by headers.

@layer components {
  *:is(h1, h2, h3, h4, h5, h6) {
    @apply flex items-center gap-2 scroll-mt-20;
  }
}

You may need to increase specificity of the selectors depending on your markup, e.g., article *:is(h1, h2, h3, h4, h5, h6).

Changelog