CSS Custom Highlight API: Highlighting Text Ranges Without DOM Changes

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.