CSS Custom Highlight API and ::highlight()

The CSS Custom Highlight API provides a performant mechanism for styling arbitrary text ranges directly from JavaScript without modifying the underlying DOM structure. By decoupling text selection logic from HTML markup, developers can register ranges into a global registry and style them declaratively using the ::highlight(custom-name) pseudo-element. This article explains how the API works, how to register highlights in JavaScript, and how to style them using CSS.

The Problem with Traditional Text Styling

Traditionally, dynamically styling text—such as search result highlights, spell-checking markers, or collaborative editing cursors—required wrapping target text inside inline HTML elements like <mark> or <span>. This approach introduces significant drawbacks:

  • DOM Thrashing: Creating, inserting, or removing wrapper tags forces style recalculations and layout reflows, causing performance bottlenecks in large documents.
  • State Issues: Modifying the DOM can reset selection ranges, interfere with text mutation observers, and disrupt accessibility trees.

The CSS Custom Highlight API solves these issues by styling text ranges in an overlay layer without altering the DOM hierarchy.

How the API Works

Using the API involves a four-step process spanning JavaScript and CSS:

  1. Create Ranges: Create one or more standard JavaScript Range objects identifying the text offsets to style.
  2. Instantiate a Highlight: Pass the range objects to a new Highlight() constructor.
  3. Register the Highlight: Add the Highlight instance to the CSS.highlights registry with a custom identifier.
  4. Apply CSS: Style the registered name using the ::highlight(custom-name) pseudo-element.

Step-by-Step Implementation

1. Creating and Registering Ranges in JavaScript

First, identify the text nodes and create standard DOM Range instances:

// Target a text node
const textNode = document.querySelector("#content").firstChild;

// Define the start and end offsets
const range1 = new Range();
range1.setStart(textNode, 0);
range1.setEnd(textNode, 5);

const range2 = new Range();
range2.setStart(textNode, 10);
range2.setEnd(textNode, 18);

// Group the ranges into a Highlight object
const searchResults = new Highlight(range1, range2);

// Register the highlight under a custom name
CSS.highlights.set("search-results", searchResults);

2. Styling the Highlight in CSS

Once registered, the highlight name can be targeted globally or scoped to specific elements using the ::highlight() pseudo-element:

::highlight(search-results) {
  background-color: #fde047;
  color: #000000;
  text-decoration: underline;
}

You can also scope highlights to specific containers:

article.search-area::highlight(search-results) {
  background-color: #38bdf8;
  color: #ffffff;
}

Supported CSS Properties

Because highlight pseudo-elements do not create standard layout boxes, they only accept a restricted subset of CSS properties—similar to ::selection:

  • color
  • background-color
  • text-decoration and its longhands (text-decoration-line, text-decoration-color, etc.)
  • text-shadow
  • stroke-color, fill-color, and stroke-width (for SVG text)

Properties that alter layout (such as margin, padding, display, or font-size) are ignored.

Handling Overlapping Highlights and Priorities

When multiple custom highlights overlap on the same text, conflicts are resolved using the priority property. Highlights with a higher priority number render on top of those with lower numbers:

const userNote = new Highlight(range1);
userNote.priority = 1;

const syntaxError = new Highlight(range1);
syntaxError.priority = 2; // Renders on top of userNote

CSS.highlights.set("user-note", userNote);
CSS.highlights.set("syntax-error", syntaxError);

If priorities are equal, the order in which the highlights were registered in CSS.highlights determines rendering order, with the most recently added highlight taking precedence. Default browser selections (::selection) always render on top of custom highlights.