BlogProject

Text Fragment URL Generator

Have you ever wanted to share a link that lands someone on a specific sentence rather than the top of the page? Text fragment links1 do that, and this tool generates them.

Anchor links solved the same problem, badly, and only when the page author bothered to add an id to the thing you wanted to point at. Text fragments work on any page, whether or not anyone prepared for it.

The syntax hangs off a #:~: directive:

https://example.com/article#:~:text=the text you want to highlight

There’s a range form, #:~:text=Once upon a time,happily ever after, which highlights everything between the two phrases. And a prefix/suffix form for when the same words appear more than once on the page:

#:~:text=Chapter 1-,I was born at Blunderstone,-A key event

That one finds “I was born at Blunderstone” preceded by “Chapter 1” and followed by “A key event”. The hyphens are what mark the prefix and the suffix. Hold that thought, because it’s about to ruin my afternoon.

The bug I shipped

The tool builds the fragment by encoding each part and joining them:

const encode = encodeURIComponent;
const parts = [];

if (values.prefix) parts.push(`${encode(values.prefix)}-`);

if (values.text) {
parts.push(encode(values.text));
} else {
parts.push(encode(values.textStart));
if (values.textEnd) parts.push(encode(values.textEnd));
}

if (values.suffix) parts.push(`-${encode(values.suffix)}`);

return `text=${parts.join(",")}`;

Looks fine. It isn’t. encodeURIComponent does not escape hyphens, and it never has: -, _, ., !, ~, *, ', ( and ) all pass through untouched because they’re unreserved characters in a URI component. In almost every context that’s what you want.

Here it means any hyphen in your text goes into the URL as a live delimiter. I generated a link for “state-of-the-art rendering” and it quietly matched nothing.

I set up a page with a few paragraphs in it and drove Chromium through each case with a real cross-origin click, which is what the activation rules require. The output was blunter than I expected:

encodeURIComponent (what the tool did)
FAIL  "state-of-the-art rendering"
FAIL  "foo-bar baz"
FAIL  "trailing dash-"
FAIL  "-leading dash"
OK    "no dashes here"

encodeURIComponent, then hyphens replaced with %2D
OK    "state-of-the-art rendering"
OK    "foo-bar baz"
OK    "trailing dash-"
OK    "-leading dash"
OK    "no dashes here"

Not an edge case about trailing hyphens, which is what I assumed when I started. Any hyphen, anywhere in the string, kills the match. Leading, trailing, buried in the middle of a compound adjective. The parser splits on commas, then checks whether the first part ends with - and the last part starts with -, and an unescaped hyphen elsewhere is enough to send that logic somewhere useless.

The fix is one line:

const encode = (s) => encodeURIComponent(s).replace(/-/g, "%2D");

Commas and ampersands were never a problem, incidentally. encodeURIComponent turns those into %2C and %26 already. The hyphen is the only structural character it lets through.

What annoys me about this is how quiet it is. There’s no console warning, no failed navigation, no visual difference. The page loads at the top, exactly as if you’d sent a normal link, and you assume the recipient’s browser is old.

The other way it fails

The text has to be in the document when the browser goes looking. That sounds obvious until you remember how much of the modern web renders late.

I added a paragraph to my test page 1.2 seconds after load and pointed a fragment at it. No match. The browser searched, found nothing, and gave up without retrying.

So text fragments break on anything that defers its content: client-side routed views, infinite scroll, anything behind a loading spinner. They also break on virtualised lists, where the whole point is that most of the rows don’t exist in the DOM. Linking into row 4,000 of a virtualised table cannot work, and no amount of escaping will save it.

Collapsed <details> elements do work, which surprised me. I expected a closed disclosure widget to hide its contents from the matcher, but Chromium found the text and scrolled to it. Hidden-until-found content is handled deliberately, so that one’s fine.

The directive is invisible to the page

One detail worth knowing if you ever try to read a fragment from JavaScript: you can’t.

Navigate to https://example.com/doc#:~:text=quick%20brown%20fox and the page sees this:

location.href  // "https://example.com/doc"
location.hash  // ""

The :~: part is a fragment directive, and the browser strips it out before handing the URL to the document. Every one of my test navigations came back with an empty hash. It’s a privacy measure: without it, any page could read what phrase you arrived searching for, which leaks whatever you were reading elsewhere.

It also means you cannot polyfill this, and you cannot log it.

Is it safe to use

Yes, and more so than when I first built the pen. Chrome has had it since version 81 in 2020, Safari since 16.1 in 2022, and Firefox since 131 in 2024, which was the last real gap. Global support is around 92%2. The failure mode for the remaining browsers is that the link works normally and lands at the top of the page.

One habit worth forming: prefer a short match with a prefix and suffix over a long verbatim quote. A twelve word match is a hostage to the next person who fixes a typo inside it. Three words anchored by their neighbours survive that, and the URL stays readable.

FAQ

Why doesn't my text fragment link work?

The most common cause is a hyphen in the highlighted text. The fragment grammar uses the hyphen to mark the prefix and suffix, so an unescaped one anywhere in your text changes how the whole thing parses and the match fails silently. Percent-encode it as %2D. The second most common cause is that the text isn't in the page yet when the browser looks for it.

Can JavaScript on the page read the text fragment?

No. The browser strips the fragment directive from the URL before the page sees it, so location.hash and location.href come back without it. That's deliberate: it stops a page from working out what brought you there.

Which browsers support text fragments?

All of the current ones. Chrome shipped it in version 81 in 2020, Safari in 16.1 in 2022, and Firefox in 131 in 2024. Support sits around 92% globally, so the technique is safe to use without a fallback.

Why use prefix and suffix instead of a longer text match?

A long match breaks the moment anyone edits a word inside it. A short match anchored by the words around it survives edits to the surrounding paragraph, and it keeps the URL shorter.

References

  1. Text fragment linksdeveloper.mozilla.org
  2. Global support is around 92%caniuse.com