Adding heading anchor links to an Eleventy site

Heading anchor links sit next to a heading on a page, and provide a URL to that particular heading when clicked on. They're useful for directing someone to a particular part of a long article. A common pattern is for the link to be shown as an icon which only appears when you hover over the heading.

They're made up of two things:

  • a fragment identifier: an id on an element that is/introduces the start of a section on the page, e.g. the heading element)
  • an anchor element which links to the fragment identifier

Here's how it might look in HTML. Note that to make this accessible, I've hidden the svg icon with aria-hidden="true" and added some text to describe the link - this would be visually hidden from the user with the use of the .screen-reader-only class.

<h2 id="here-is-a-heading">
<a href="#here-is-a-heading">
<svg aria-hidden="true"><!-- icon goes here --></svg>
<span class="screen-reader-only"
>
Direct link to this heading</span
>

</a>
Here is a heading
</h2>

A good live example of this can be seen when viewing a Markdown file on GitHub (like a README.md), where hovering over the headings shows you the link icon:

A readme file on GitHub with a heading anchor link.

In Eleventy (the static site generator used to build this site), content is written in Markdown. Rather than manually write the HTML for heading anchor links, I wanted to auto-generate them.

With some variants of Markdown there's syntax to generate ids on some elements (e.g. Markdown Extra), but Eleventy uses markdown-it which does not include this feature by default. However, there are several plugins which extend the capabilities of markdown-it.

Option 1: markdown-it-github-headings Direct link to this section

This plugin completely replicates the GitHub style of heading anchor links - even including the GitHub icon (though you can replace it if you want). It will give you HTML like this:

<h2>
<a
id="this-is-a-heading"
class="anchor"
href="#this-is-a-heading"
aria-hidden="true"
>

<svg aria-hidden="true"><!-- icon code --></svg>
</a>
This is a heading
</h2>

It could be a good choice for you if you don't want to do much setup and don't need to customise the way the link renders.

A note on accessibility Direct link to this section

Due to the aria-hidden="true" attributes on both the anchor element and the svg, the link generated by this plugin will be inaccessible to screen readers. While users of screen readers can navigate through a document by headings, they might still want to use the direct link feature, so bear that in mind.

Option 2: markdown-it-anchor Direct link to this section

This plugin has many more options than markdown-it-github-headings, and attaches the id to the heading element instead of the anchor. The HTML looks like this:

<h2 id="this-is-a-heading">
This is a heading
<a class="header-anchor" href="#this-is-a-heading"></a>
</h2>

Another note on accessibility Direct link to this section

In screen readers, the paragraph symbol (¶) that's used by default for the anchor link is read out as "paragraph" (source) which is not very descriptive. However, you can completely control the way the link renders with an optional render function in the options, which I'll show below.

Installing markdown-it plugins on Eleventy Direct link to this section

Out of the two plugins above, I prefer markdown-it-anchor because of the greater control you have over the output of the anchor link.

Add the plugin as a project dependency by running npm install markdown-it-anchor --save-dev in the root of the project, or wherever your package.json file is.

In your .eleventy.js config file, modify the default markdown install as shown below. Note: we don't need to install the markdown-it library because it's a dependency of Eleventy, but we do need to grab a reference to it to install the markdown-it-anchor plugin.

const markdownIt = require("markdown-it")
const markdownItAnchor = require("markdown-it-anchor")

module.exports = (eleventyConfig) => {
// Options for the `markdown-it` library
const markdownItOptions = {
html: true,
}

// Options for the `markdown-it-anchor` library
const markdownItAnchorOptions = {
permalink: true,
}

const markdownLib = markdownIt(markdownItOptions).use(
markdownItAnchor,
markdownItAnchorOptions
)

eleventyConfig.setLibrary("md", markdownLib)

return {
// Eleventy config options go here
}
}

Note that we need to provide a set of options for both the markdown-it and the markdown-it-anchor libraries, because we're overriding the default markdown-it setup that Eleventy uses under the hood. By default, Eleventy only sets html: true (see source file) but you could provide other markdown-it options here - or remove the options entirely.

To render the permalink, we have to pass permalink: true to the markdown-it-anchor options, and there are several other options available.

Provide an additional key, renderPermalink, to the options object. The value must be a function, which we can base off of the renderPermalink function in the plugin's source code.

In the commented section of code below, I've modified the output HTML inside the heading anchor link. I don't want the symbol inside the link to be read out by screen readers, so it's wrapped in a <span> element with the attribute aria-hidden="true". This will also be helpful when styling the link later on. And I do want more descriptive link text for users of screen readers, so I've added some screen-reader-only text which reads "Direct link to this section".

// This object is required inside the renderPermalink function.
// It's copied directly from the plugin source code.
const position = {
false: "push",
true: "unshift",
}

// Copied directly from the plugin source code, with one edit
// (marked with comments)
const renderPermalink = (slug, opts, state, idx) => {
const space = () =>
Object.assign(new state.Token("text", "", 0), {
content: " ",
})

const linkTokens = [
Object.assign(new state.Token("link_open", "a", 1), {
attrs: [
["class", opts.permalinkClass],
["href", opts.permalinkHref(slug, state)],
],
}),
Object.assign(new state.Token("html_block", "", 0), {
// Edit starts here:
content: `<span aria-hidden="true" class="header-anchor__symbol">#</span>
<span class="screen-reader-only">Direct link to this section</span>
`
,
// Edit ends
}),
new state.Token("link_close", "a", -1),
]

if (opts.permalinkSpace) {
linkTokens[position[!opts.permalinkBefore]](space())
}
state.tokens[idx + 1].children[position[opts.permalinkBefore]](
...linkTokens
)
}

module.exports = (eleventyConfig) => {
// Eleventy config goes here...

// Options for the `markdown-it-anchor` library
const markdownItAnchorOptions = {
permalink: true,
renderPermalink, // Provide our custom function
}

// Eleventy config goes here...
}

I had several requirements for styling the heading anchor link:

  • Users who can navigate with the mouse should see it when they hover the heading
  • Users who navigate via the keyboard should see the link when tabbing through the page
  • Users who are on a device without hover, or on smaller screens, should see the link at all times
    • We can test for hover capabilities with the hover media feature, part of the Media Queries Level 4 specification, and supported in all modern browsers.
// Only targeting h2 and downwards, because we'll only use an h1
// once per page for the title, which doesn't need a heading
// anchor link
.post {
h2,
h3,
h4,
h5,
h6
{
position: relative;
}
}

.header-anchor {
// Above this viewport width, there's space to position the link
// to the left of the heading. On smaller screens, it will have
// the implicit `position: static` and be shown after the
// heading.
@media screen and (min-width: 700px) {
left: -0.8em;
position: absolute;
width: 0.8em;
}
}

.header-anchor__symbol {
// Only hide the link on larger screens and when hover is
// available. Also, show it when headings are hovered OR
// the heading anchor link itself is focused.
@media screen and (min-width: 700px) and (hover: hover) {
visibility: hidden;

h2:hover &,
h3:hover &,
h4:hover &,
h5:hover &,
h6:hover &,
.header-anchor:focus &
{
visibility: visible;
}
}
}

// Used for the text in the anchor link.
.screen-reader-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}

Conclusion Direct link to this section

I finished up this process by testing the links in several ways:

  1. Using NVDA (a free screen reader available on Windows) to jump through the headings
  2. Checking I could tab through the site with just the keyboard
  3. Resizing the browser viewport to test different screen sizes
  4. Viewing the site on a touchscreen device

Heading anchor links are a nice addition to a site, especially for documentation, where you may want to quickly direct someone to a particular section. While implementing this on my own site I was also reminded that it's a good idea to check how third-party libraries handle accessibility, as the results might not be what you expected.

Finally, I'd like to note that I'm not a daily user of screen readers, so this solution is what made sense to me, but there's always room for improvement. I'd love to hear from any screen reader users if that's the case.