The CSS Custom Highlight API lets you style arbitrary text ranges — like search results, spelling-error underlines, or code-syntax tokens — using only a CSS pseudo-element (::highlight(name)) without inserting wrapper elements into the DOM. It works with the browser’s native Range and Highlight objects and is supported in all modern browsers as of 2024.
Problem: Highlighting search terms, text selections from annotations, or code syntax in a WordPress admin page requires wrapping matched text nodes in <mark> or <span> elements via DOM manipulation — which breaks existing event listeners and is slow for large documents.
Solution: Use the CSS Custom Highlight API — create a Highlight object from a set of Range objects, register it with CSS.highlights.set('my-highlight', highlight), and style it with ::highlight(my-highlight) { background: yellow; }. No DOM mutation occurs; the highlight is painted entirely by the browser.
The example below implements a live search highlighter for a WordPress article: it walks all text nodes in .entry-content, builds Range objects for every match, registers them as a named Highlight, and styles them via CSS.
/* Style the custom highlight — no extra DOM elements needed */
::highlight(search-results) {
background-color: #fff3a3;
color: #000;
}
::highlight(search-results-active) {
background-color: #ff9632;
color: #fff;
text-decoration: underline;
}
// Live search highlighter using the CSS Custom Highlight API
const input = document.getElementById( 'search-input' );
const content = document.querySelector( '.entry-content' );
// Collect all text nodes inside the content area
function getTextNodes( root ) {
const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT );
const nodes = [];
while ( walker.nextNode() ) nodes.push( walker.currentNode );
return nodes;
}
const textNodes = getTextNodes( content );
input.addEventListener( 'input', () => {
// Clear previous highlights
CSS.highlights.clear();
const query = input.value.trim();
if ( query.length < 2 ) return;
const ranges = [];
const pattern = new RegExp( query.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ), 'gi' );
for ( const node of textNodes ) {
const text = node.textContent;
let match;
while ( ( match = pattern.exec( text ) ) !== null ) {
const range = new Range();
range.setStart( node, match.index );
range.setEnd( node, match.index + match[0].length );
ranges.push( range );
}
}
if ( ranges.length ) {
// Register all ranges under the name 'search-results'
CSS.highlights.set( 'search-results', new Highlight( ...ranges ) );
// Highlight the first match differently
const active = new Highlight( ranges[0] );
CSS.highlights.set( 'search-results-active', active );
ranges[0].startContainer.parentElement?.scrollIntoView( { block: 'center' } );
}
} );
NOTE: Highlights registered with CSS.highlights are keyed by string name; calling CSS.highlights.set() with the same name replaces the previous highlight object, which is why clearing and re-registering on each keystroke is both correct and inexpensive.