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 highlightThere’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 eventThat 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.