
The Invisible Layer Above Your Text
Stop polluting your DOM tree with thousands of wrapper tags just to show search matches; there is a way to style text ranges that the DOM doesn't even know exists.
Most developers are unknowingly wrecking their DOM performance and accessibility every time they build a "find in page" feature. We’ve been conditioned to believe that if we want to change how a specific word looks, we *must* wrap it in a <span> or a <mark> tag. It’s a messy, destructive habit that we’ve tolerated for decades, but it’s finally time to stop.
When you use innerHTML or Range.surroundContents() to highlight search results, you aren't just changing colors; you're physically mutilating your text nodes. You're breaking screen readers, disrupting text selection, and forcing the browser to recalculate the entire layout of your document.
There is a better way. It’s called the CSS Custom Highlight API, and it allows you to paint styles directly onto the screen over your text, without the DOM ever knowing it happened.
The "Wrapper" Tax
Before we look at the better way, let’s acknowledge why the old way is so painful. Imagine you have a large technical document and you want to highlight every instance of the word "function."
In the old days, you’d do something like this:
1. Find the text node.
2. Split it.
3. Inject a <span>.
4. Rinse and repeat a thousand times.
If you have a 5,000-word article, you just turned a single text node into a graveyard of thousands of elements. Good luck managing state or handling overlapping highlights without losing your mind.
Enter the CSS Custom Highlight API
The Custom Highlight API introduces a separate layer that sits above the DOM. You define ranges of text (using standard JavaScript Range objects), group them into a Highlight object, and then style that group using a CSS pseudo-element.
The DOM tree stays perfectly clean. No new elements are created. No layout shifts occur.
A Minimal Example
Here is how you create a highlight in four lines of JavaScript:
// 1. Create a range (the "where")
const range = new Range();
range.setStart(someTextNode, 10);
range.setEnd(someTextNode, 20);
// 2. Create a Highlight object (the "what")
const myHighlight = new Highlight(range);
// 3. Register it with the browser (the "how")
CSS.highlights.set("search-results", myHighlight);Then, in your CSS, you target that specific name:
::highlight(search-results) {
background-color: #ffd700;
color: black;
text-decoration: underline;
}Building a Real-World Search Highlighter
It’s one thing to highlight a hard-coded range; it’s another to do it dynamically as a user types. The magic here is that the Highlight object is a live set. You don't have to keep re-registering it with CSS.highlights. You just add or remove ranges from the set, and the browser updates the UI instantly.
Here is a functional script that finds and highlights text without touching a single HTML tag:
const searchInput = document.querySelector('#search-box');
const articleBody = document.querySelector('article');
searchInput.addEventListener('input', (e) => {
// Clear previous highlights
CSS.highlights.clear();
const query = e.target.value.trim();
if (!query) return;
// Use the TreeWalker API to find all text nodes efficiently
const treeWalker = document.createTreeWalker(articleBody, NodeFilter.SHOW_TEXT);
const ranges = [];
let currentNode;
while (currentNode = treeWalker.nextNode()) {
const text = currentNode.textContent.toLowerCase();
let startPos = 0;
while ((startPos = text.indexOf(query.toLowerCase(), startPos)) !== -1) {
const range = new Range();
range.setStart(currentNode, startPos);
range.setEnd(currentNode, startPos + query.length);
ranges.push(range);
startPos += query.length;
}
}
// Create the highlight and register it
const searchHighlight = new Highlight(...ranges);
CSS.highlights.set("user-search", searchHighlight);
});Why This is Faster (and Smarter)
The browser treats ::highlight styles similarly to how it treats the ::selection pseudo-element (the blue highlight you see when you drag your mouse).
Because the API only allows "painting" styles—like color, background-color, text-decoration, and text-shadow—the browser knows it doesn't need to recalculate the geometry of the page. You can’t change the font-size or margin of a highlight because that would cause a reflow.
By limiting the scope of what you can change, the CSS Custom Highlight API stays incredibly performant, even with tens of thousands of ranges active at once.
The Gotchas
As much as I love this API, it isn't magic. There are two things you need to keep in mind:
1. Limited Properties: As mentioned, you can't change the layout. If you need your highlighted text to grow in size or have a border that pushes other text away, you're back to using <span>.
2. Overlapping Priorities: If you have two different highlights (say, a "search result" and a "syntax error") overlapping the same text, the CSS order usually determines who wins. However, you can set a priority:
searchHighlight.priority = 10;.
Browser Support: Is it ready?
The good news: Chromium (Chrome/Edge), Safari, and Firefox all support the CSS Custom Highlight API. We have finally reached the point where we can delete those heavy, recursive DOM-traversal-and-replacement libraries.
It’s a cleaner, more professional way to handle text. It respects the integrity of the document, keeps the accessibility tree intact, and runs circles around older methods in terms of raw speed.
Next time you need to highlight something, don't reach for a wrapper. Paint it instead.


