Text highlighting without DOM manipulation
Highlighting arbitrary text used to mean mutating the DOM: wrapping ranges in <mark> elements via innerHTML.replace() or surroundContents(). The CSS Custom Highlight API lets you style ranges directly in CSS with no DOM changes.
2::highlight(search) { background: yellow; }
3
4/* JS β no DOM changes */
5const range = new Range();
6range.setStart(node, startOffset);
7range.setEnd(node, endOffset);
8CSS.highlights.set(
9 'search', new Highlight(range));
2function highlight(el, term) {
3 el.innerHTML = el.innerHTML
4 .replace(
5 new RegExp(term, 'gi'),
6 '<mark>$&</mark>'
7 );
8}
9/* Breaks across element boundaries */
Browser Support for
Since 2025 this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers.
No DOM mutations
The DOM stays untouched. No wrapped <mark> elements to insert or remove, no lost event listeners, no broken element boundaries.
Crosses element boundaries
Range can span across multiple elements. The old surroundContents() approach throws when a range crosses tag boundaries.
Multiple highlight layers
Register as many named highlights as you need. Each gets its own ::highlight(name) pseudo-element and its own styles.
How it works
The classic approach was innerHTML.replace(): grab the element's HTML as a string, wrap matches in <mark>, and write it back. This nukes event listeners, breaks React and other framework rendering, and silently fails when a match crosses a tag boundary.
The CSS Custom Highlight API keeps highlighting entirely separate from the DOM. You create a Range, pass it to new Highlight(range), register it with CSS.highlights.set('name', highlight), and style it with the ::highlight(name) pseudo-element in CSS. No elements are created or destroyed.
To clear highlights, call CSS.highlights.delete('name') or CSS.highlights.clear(). Firefox 140 shipped support in June 2025, completing full cross-browser coverage. This is an Interop 2026 focus area.