← blog
CSSSeptember 13, 2026 · 6 min read

The Search Highlight That Deletes Your Selection

A search-highlight feature that rebuilds its DOM on every keystroke can wipe out a user's own text selection with no warning. The CSS Custom Highlight API fixes it by styling text ranges without ever touching a DOM node.

Parsa Jiravand · Frontend engineer · building bestpractic
The Search Highlight That Deletes Your Selection

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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
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?

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 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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)); }
CSS
1
2
3
4
::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.

Runs right in your browser — poke at it and watch the concept react live.

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":

JavaScript
1
2
3
4
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.

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.

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:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.