A bug report lands: "I selected a paragraph to quote it, hit copy, and pasted nothing." No screenshot that shows anything wrong, no repro steps that make sense. The reporter insists they didn't touch anything except drag-select some text and press ⌘C.
They're right. They didn't touch anything else. But a few seconds earlier they'd typed into the page's search box, and that box was still doing its job — highlighting every match in the paragraph they'd just selected. It rebuilt that paragraph from scratch, the way search-highlight boxes have rebuilt paragraphs for years. Their selection wasn't corrupted. It was deleted, node by node, by code that had nothing to do with copying text at all.
The two-line "fix" that is the bug
Here's the version almost everyone reaches for first — find matches with a regex, wrap them in <mark>, and shove the result back in with innerHTML:
function highlight(container, rawText, query) {
if (!query) {
container.textContent = rawText;
return;
}
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(${escaped})`, "gi");
container.innerHTML = rawText.replace(re, "<mark>$1</mark>");
}
Wire it to an input event on a search box, and every keystroke re-runs it. It looks completely reasonable — and on dev.to it would even work, right up until you tried to select and copy something out of that paragraph while it was live.
Guess before you scroll: which line breaks the user's selection — and does it matter whether the selected text is one of the highlighted matches?
Why innerHTML = breaks something it never mentions
It's container.innerHTML = …. Not the regex, not the <mark> tag — the assignment itself. Setting innerHTML doesn't patch the existing children; it removes every one of them and parses a brand-new set from the string you gave it. There's no diffing, no reuse.
That matters because the browser's own text selection is a Range — two boundary points, each a reference to a specific node plus an offset into it. The moment the node a boundary point references is removed from the document, that reference has nothing left to point at, and the selection collapses to nothing. This happens unconditionally. It doesn't matter whether the text the user selected contains a search match, whether the match is at the start or the end of the paragraph, or whether the query is even one character long. Every child node is gone, so every Range anchored to those children is gone with it — including the one behind the highlight-yellow ::selection the user is staring at.
The naive fix people try next — remember the selection's start/end offsets, rebuild, then reconstruct a new Range at the same offsets and reapply it with selection.addRange() — technically works, but now you're hand-rolling selection preservation on every keystroke, for a feature that's supposed to be presentation only. That's the tell that you're solving the problem on the wrong layer.
The fix: style the text, don't touch the tree
The actual bug isn't "the wrong DOM surgery" — it's that this needed DOM surgery at all. Highlighting a search match is a pure styling decision: which pixels get a background color. It never needed to change what node structure the paragraph has. The CSS Custom Highlight API does exactly that:
function highlight(container, query) {
CSS.highlights.delete("search-results");
if (!query) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const q = query.toLowerCase();
const ranges = [];
let node;
while ((node = walker.nextNode())) {
const text = node.textContent.toLowerCase();
let i = 0;
while ((i = text.indexOf(q, i)) !== -1) {
const r = new Range();
r.setStart(node, i);
r.setEnd(node, i + q.length);
ranges.push(r);
i += q.length;
}
}
if (ranges.length) CSS.highlights.set("search-results", new Highlight(...ranges));
}
::highlight(search-results) {
background-color: gold;
color: #111;
}
Nothing here inserts, removes, or splits a single node. container.innerHTML is never touched — the paragraph's text nodes are exactly the ones the browser parsed on page load, forever. A Highlight is just a named set of Ranges registered on CSS.highlights; the browser paints the styling as an overlay, the same way it paints native text selection. Whatever the user has selected with their mouse lives in its own untouched Range, on its own untouched nodes, completely unaware that a search feature exists.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Layering highlights without a turf war
Real pages rarely have just one highlight. A code viewer might run syntax coloring and search-result highlighting over the same block, and they will overlap. Highlight objects have a priority property for exactly this: when two highlights cover the same character, the one with the higher priority paints on top, and for conflicting properties the styles apply in ascending priority order — last write wins. Leave priority unset and both default to 0, so ties go to whichever Highlight was most recently registered, which is rarely what you want for "search results should always sit above syntax coloring":
CSS.highlights.set("syntax", new Highlight(...syntaxRanges)); // priority 0
const searchHl = new Highlight(...searchRanges);
searchHl.priority = 1; // always wins the overlap
CSS.highlights.set("search-results", searchHl);
One more layer sits above both, permanently: the browser's own built-in highlight pseudo-elements — ::selection, ::spelling-error, and friends — always paint on top of every custom highlight, regardless of priority. You can't outrank the user's own selection even if you tried, which is one more reason the old innerHTML approach was fighting the platform instead of using it.
Where this actually stands today
This isn't an experimental flag anymore. Chrome and Edge have shipped it since version 105 (2022), Safari added it in 17.2 (December 2023), and Firefox caught up in 140 (June 2025) — which is what pushed it to Baseline: newly available as of last year. One gap worth knowing before you ship: Firefox's implementation doesn't yet apply text-decoration or text-shadow inside ::highlight(), though background-color, color, and font-weight all work everywhere. Stick to those three for anything cross-browser, and treat underlines or shadows as a Chromium/Safari bonus, not a dependency.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The reporter's paste wasn't empty because of a rendering glitch or a browser quirk — it was empty because a completely unrelated feature had, as a side effect nobody designed on purpose, deleted the exact nodes their selection depended on. That's what happens when "just restyle this text" gets implemented as "rebuild this text." If you're only changing how a range of text looks, there's now a browser API that never touches how it's structured — use it instead of innerHTML.
Go grep your own codebase for innerHTML = next to a regex and a <mark> tag. How many of those are one debounced keystroke away from eating someone's clipboard?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (2)
This is the same class of bug as a textarea that gets rewritten while the user is typing, and it's invisible in every test that sets the value programmatically. The ordering is what makes it nasty: the selection was valid, then a later DOM mutation invalidated it without any event firing on the selection itself.
What I've landed on for highlighters is keeping match ranges as offsets into the text and rendering marks in a separate pass, so re-highlighting never touches the nodes holding the user's selection. I got bitten the other way too — a MutationObserver firing on our own mark elements, so the observer's own writes counted as user changes. Did you end up debouncing the rebuild, or moving to the CSS Custom Highlight API?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.