Add bookmark links to your blog to make it easy to reference

Add bookmark links to your blog to make it easy to reference

It is kind of an informal industry standard to have a bookmark link (also called anchor links) in the headings of a page. The link text is typically a link icon (🔗) or a hash symbol (#). The idea is that you can click this link and get an URL that points to that section of the page. It is a bit odd to click a link, have the page scroll down to the section exactly, and then copy the link from the address bar to share it with others. But that is what is done usually.

You can see how some websites have implemented the links in figure 1-0 below. GitHub only shows the link when you hover on the heading. CSS Tricks and Smashing Magazine always show the link, however the link text has a lower color contrast ratio than the rest of the text, but when you hover over it, it gets brighter. GitHub and CSS Tricks place the link at the very beginning of the heading, Smashing Magazine places it right at the end of the heading. Variations on the theme.

simple cover image featuring a copy content icon
Figure 1-0. Examples of bookmark links from around the web (GitHub, CSS Tricks, Smashing Magazine)

Today, I will show you how you can write some code to add these links to a page. And I will offer an alternative version, why not just add a button that will copy the URL to the system clipboard for you?

And now, there is a web specification that adds some query powers to text fragments, so you can reference any part of a webpage in an URL, and you don’t have to rely on the page-author to do anything for you!

Let’s explore these options.

See the Pen Add bookmark links to headings by Rob (@robjoeol) on CodePen.

N.B. Codepen runs code in a iframe, so the bookmark links don’t point to a valid external URL. If you run the same code in a page, the links are perfectly valid.

To create a bookmark, we add an unique ID to an element.

<h2 id="my-bookmark">How to create a bookmark</h2>

Remember that there are a few rules for a valid ID name:

To create a link to that heading, the URL must contain a text fragment that matches our ID. A text fragment is specified by a hash.

<a href="#my-bookmark">Jump to the heading</a>

The above example is only valid within the same page. You must use an absolute URL if you want to share it with others e.g. https://www.roboleary.net/2022/01/13/copy-code-to-clipboard-blog.html/#my-bookmark.

So, to create bookmark links for all of our headings, we need to:

  1. Add unique IDs to all of our headings except h1
  2. Insert a link into these headings, set the href to an absolute URL that includes the ID as a text fragment.

Let’s write the code then!

We can get all of our headings with document.querySelectorAll("h2, h3, h4, h5, h6"). We want to loop through each of these headings and add an id. We must come up with a way to create an unique ID for each heading, a common way to do this is to use the text of the heading to generate a “slug” (that’s what the cool kids call it). We will discuss the slugify function in more detail below.

A slug is a human-readable, unique identifier, used to identify a resource instead of a less human-readable identifier like an id. You use a slug when you want to refer to an item while preserving the ability to see, at a glance, what the item is.

-- What’s a slug and why would I use one? by Dave Sag

For each heading, we must create an anchor element (a) and set its href attribute to the current URL plus the slug as a text fragment. We use the global object window.location to get the page’s URL info. We build our own URL from the pieces rather than use window.location.href. We do this because window.location.href includes the text fragment, if someone were to follow a link with a text fragment to the page and we used window.location.href in our code, we would create a bookmark link with 2 text fragments. Not the outcome we want! Once the link is created correctly, we append it to the heading.

let headings = document.querySelectorAll("h2, h3, h4, h5, h6");

// we construct this URL ourselves to exclude the text fragment
const currentURL = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;

headings.forEach((heading) => {
let slug = slugify(heading.innerText);
heading.setAttribute("id", slug);

const bookmarkLink = document.createElement("a");
bookmarkLink.innerText = "#";
bookmarkLink.setAttribute("href", `${currentURL}#${slug}`);
heading.append(bookmarkLink);
});

In our slugify function, we want to generate a slug that has no whitespace, and does not have any unwanted punctuation characters. While all punctuation characters are allowed in an id name, it is common practice to only include hyphens and underscores, probably for the sake of readability. We can use a regular expression (regex) in the replace() function to remove the unwanted charcters, and replace any spaces with hyphens. I will use something similar to GitHub’s algorithm, which uses a weird-looking regex, but no doubt it has been battle-tested by now!

function slugify(text) {
// Everything except our "safe" characters
const PUNCTUATION_REGEXP = /[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu;

let slug = text.trim().toLowerCase();
slug = slug.replace(PUNCTUATION_REGEXP, "").replace(/ /g, "-");
return slug;
}

Here is a literal description of the PUNCTUATION_REGEXP:

" Globally match a single character not present in the list below:

We use the regex to remove anything that is not in our “character safe list”. When you use a regex which contains unicode properties, any expression in the form of \p{}, you must use the /u flag also. We do a second replacement to replace spaces with a hyphen.

If you choose to go the standard route, there are some challenges with styling and accessibility. Amber Wilson has a discussion on how screen readers handle the different implementations. Her conclusion is that there is not really a silver bullet to creating accessible anchor links that suit everyone. There are quite a few ways to approach it, each with their own advantages and disadvantages.

This is where the content of the heading is wrapped in a link.

<h2 id="introduction">
<a href="#introduction">
Introduction
</a>
</h2>

This is probably easiest implementation. It is accessible out of the box by default, and is easy to style. The main caveat of this approach is that you can’t include other links inside headers.

You can find this style on the MDN, HTTP Archive and Web Almanac. This is a good sign that this is a thoughtful way of implementing permalinks. This is also the style that I chose for my own blog.

Advantages
  1. Simple implementation.
  2. It is accessible by default
  3. Its a bigger tap area for mobile
  4. You don’t need to worry about label text.
Disadvantages
  1. Can’t include another link inside heading.
  2. It’s harder to copy just the heading text. This is not something you need to do often.
  3. This pattern breaks reader mode in Safari. This was already reported to Apple but their bug tracker is not public

If you want to customize further the screen reader experience of your permalinks, this style gives you much more freedom than option 1.

It works by leaving the header itself alone, and adding the permalink after it, giving you different methods of customizing the assistive text. It makes the permalink symbol aria-hidden to not pollute the experience, and leverages a visuallyHiddenClass to hide the assistive text from the visual experience.

<div class="wrapper">
<h2 id="title">Title</h2>
<a class="header-anchor" href="#title">
<span class="visually-hidden">Permalink to “Title”</span>
<span aria-hidden="true">#</span>
</a>
</div>

A visually hidden element is used for the assistive text because aria-label is notoriously bad with content translating services.

Advantages
  1. Accessible.
  2. More control over how assistive technology announces the link.
Disadvantages
  1. Requires more effort for styling.

This style has accessibility issues that can be mitigated, but generally should be avoided.

<h2 id="title">
<a class="header-anchor" href="#title">
<span class="visually-hidden">Jump to heading</span>
<span aria-hidden="true">#</span>
</a>
Title
</h2>

If you use a symbol like just # without adding any markup around, screen readers users commonly request the list of all links in the page, so they’ll be flooded with “number sign, number sign, number sign” for each of your headings.

I would highly recommend using one of the markups above which have a better experience. If you really want to use this markup, make sure to pass accessible text for the link to make things usable, like in the example below, but even that has some flaws.

My proposed alternative is to use a button instead of a link. The button copies the bookmark URL to the system clipboard. A snackbar message informs the user that the URL has been copied to the clipboard. I think this is a more convenient way of doings things.

See the Pen Bookmark links as copy to clipboard button by Rob (@robjoeol) on CodePen.

N.B. Codepen runs code in a iframe, so the bookmark links don’t point to a valid external URL. If you run the same code in a page, the links are perfectly valid.

async function copyLink(event) {
const button = event.srcElement;
let text = button.getAttribute("data-href");
await navigator.clipboard.writeText(text);
showSnackbar();
}

We can asynchronously write to the system clipboard through the Clipboard API, using the writeText() function. The browser support is excellent (for writing to the clipboard).

We show a snackbar message when the button is pressed. We use the Web Animations API to fade in and move the snackbar further into view. The Web Animations API is a cleaner of way of running a once-off animation, the alternative is to add a class that has an associated CSS animation, and then remove it via setTimeout() a few seconds later. You can see the function showSnackbar() for the details.

Text fragment directive specification

Text fragments can now include a text query. Upon clicking a link with a text query, the browser finds that text in the webpage, scrolls it into view, and highlights the matched text. This enables links to specify which portion of the page is being linked to, without relying on the page-author annotating the page with ID attributes.

The fragment format is: #:~:text=\[prefix-,]textStart[,textEnd\][,-suffix].

In its simplest form, the syntax is as follows: The hash symbol # followed by :~:text= and finally textStart, which is the percent-encoded text I want to link to. Here is a simple example you can test in your browser to take you to the text “Adding feedback for completion of task” from my last article:
https://www.roboleary.net/2022/01/13/copy-code-to-clipboard-blog.html#:~:text=Adding feedback for completion of task

navigating to an URL with a text fragment showing the highlighted text in the body of the webpage

You can check out the article, Boldly link where no one has linked before: Text Fragments, for further explanation and examples.

At the moment, this feature is only available in Edge and Chrome. It is still early days, but I think this should be something that we start to use wholesale.

Final word

Having the ability to cross-reference specific parts of other webpages is an often overlooked feature of the web that is of great benefit to readers. You are saving a reader from foraging through a page to find the right section themselves - maybe they want to read more of the passage of text, or maybe they want to verify the source of a quotation.

It does seem strange that we are still adding links to headings if the purpose is to provide someone with an URL to a section of a page.

Is it better to add a button that will copy it to the clipboard instead like I have demonstrated?

I think it is a bit better because you are giving a reader what they need in a single action.

I hope that more browsers implement the text fragment directive soon. It would be great to break the dependence of the reader on the page-author to add IDs to headings to enable referencing of sections. And along with that, it would be great if the awareness of this feature grew, so that people would start using it regularly. I hope this article will go a little way to raising awareness of this new feature!

Tagged