CSS Custom Highlights API: Style Text Highlights Programmatically
AI generated
CSS · DOM · Highlighting · Accessibility
CSS Custom Highlights API
Highlight Text Without DOM Mutation

Highlighting search terms, visualizing diff changes, marking voice recognition matches: until now, all of this required wrapping text nodes in span elements, which corrupts the DOM and jeopardizes accessibility. The CSS Custom Highlights API solves this problem elegantly: define ranges, register them, style them with ::highlight(), done.

11 min read ::highlight() · Range · HighlightRegistry · CSS.highlights Chrome 105+ · Firefox 117+ · Safari 17.2+

1. The Problem With span Injection for Text Highlighting

The traditional way to highlight text on a web page, for example to emphasize search matches in an article, is to wrap the matching text in a <span> element with a highlight class. That sounds simple, but it has serious side effects. First, the DOM tree gets mutated: text nodes are split, new elements are inserted, and the semantic markup changes. This can break existing JavaScript event listeners that reference specific DOM nodes. Second, undoing it is expensive: every inserted <span> element has to be removed again and the text nodes merged back together, a process that is error prone and slow.

The CSS Custom Highlights API, officially the CSS Custom Highlight API, solves this problem in a completely different way. Instead of manipulating the DOM, JavaScript Range objects are defined that describe text regions, and these are stored in a global CSS.highlights registry. The corresponding ::highlight(name) pseudo element in the stylesheet then takes care of the visual styling. The DOM stays untouched: no text nodes are split, no new elements are inserted. That makes the CSS Custom Highlights API a semantically clean, performant, and accessibility friendly alternative to span injection.

2. Architecture of the CSS Custom Highlights API

The CSS Custom Highlights API consists of three layers that build on each other. The first layer is Range objects, the standard DOM API that describes an arbitrary text region through a start node, start offset, end node, and end offset. Ranges are not new; they have always been used for the browser's native text selection. The Custom Highlights API opens up this mechanism for custom use cases.

The second layer is the Highlight object, a set of Range objects with an optional priority. Multiple ranges can be combined into a single Highlight, which is especially useful for search features that highlight many matches at once. The third layer is CSS.highlights, a global HighlightRegistry object that stores Highlight objects under custom names. Each name in the registry corresponds to a ::highlight(name) pseudo element in the stylesheet. Together these three layers, Range, Highlight, and HighlightRegistry, form the complete system of the CSS Custom Highlights API.


/* CSS Custom Highlights API: stylesheet side */

/* Register a custom highlight named "search-result" */
::highlight(search-result) {
  background-color: #c4b5fd;  /* violet highlight */
  color: #1e1b4b;             /* dark text for contrast */
}

/* Multiple highlight types: different visual treatments */
::highlight(search-active) {
  background-color: #7c3aed;  /* current match, stronger */
  color: #ffffff;
  border-radius: 2px;         /* limited properties supported */
}

::highlight(diff-added) {
  background-color: #bbf7d0;  /* green for additions */
  color: #14532d;
}

::highlight(diff-removed) {
  background-color: #fecaca;  /* red for removals */
  color: #7f1d1d;
  text-decoration: line-through;
}

/* Highlight with priority: higher z-index wins visually */
::highlight(grammar-error) {
  text-decoration: wavy underline #ef4444;
  /* Only a subset of CSS properties are allowed in ::highlight() */
  /* background-color, color, text-decoration, outline, caret-color */
}

3. Range Objects: Defining Text Regions Precisely

A Range object describes a text region in the DOM without changing it. document.createRange() creates an empty Range object. range.setStart(node, offset) and range.setEnd(node, offset) then define the region: node is a text node, offset is the character position within that text node. For the CSS Custom Highlights API, ranges always need to point to text nodes, not element nodes, even though the Range API generally supports element nodes too.

To find every occurrence of a search term in a document and store them as ranges, a combination of TreeWalker and string matching is typically used. The TreeWalker traverses every text node in the document, and for each text node, indexOf or a regular expression is used to search for the term. A new Range object is created for every match. These ranges are then collected and stored as a Highlight in the registry. The entire search happens in JavaScript; the DOM is never touched, which is the decisive advantage of the CSS Custom Highlights API.


/* Allowed CSS properties in ::highlight() pseudo-element */
/* Source: CSS Custom Highlight API Level 1 specification  */

::highlight(example) {
  /* ✓ Background */
  background-color: rgba(196, 181, 253, 0.4);

  /* ✓ Text color */
  color: #1e1b4b;

  /* ✓ Text decoration */
  text-decoration: underline wavy #7c3aed;
  text-decoration-thickness: 2px;

  /* ✓ Outline */
  outline: 2px solid #4a1d96;
  outline-offset: 2px;

  /* ✓ Caret color (for editable content) */
  caret-color: #7c3aed;

  /* ✗ NOT allowed, will be silently ignored */
  /* border-radius: 4px; */
  /* padding: 2px 4px; */
  /* font-weight: bold;  */
  /* display: inline-block; */
  /* box-shadow: ...; */
}

/* Priority: higher number wins when highlights overlap */
/* Set via Highlight constructor: new Highlight(...ranges, { priority: 1 }) */
/* Default priority is 0 */

4. HighlightRegistry and CSS.highlights

CSS.highlights is the global HighlightRegistry object, a Map like interface with the methods set(name, highlight), get(name), has(name), delete(name) and clear(). Every name is a string that corresponds directly to the parameter of the ::highlight(name) pseudo element in the stylesheet. The name mapping is case sensitive. A name like "search-result" in JavaScript corresponds exactly to ::highlight(search-result) in CSS, with a hyphen and without quotation marks in the CSS.

The CSS Custom Highlights API is reactive: whenever ranges are added to or removed from a Highlight object, the browser updates the visual rendering automatically without the stylesheet needing to reload. That makes the API ideal for real time features like live search: on every keystroke, the old ranges are removed from the Highlight object or the Highlight object is replaced, and the browser renders the updated markup immediately. The Highlight object itself is also a set, with the methods add(range), delete(range) and clear().

5. The ::highlight() Pseudo Element and Allowed Properties

The ::highlight() pseudo element is the CSS side of the CSS Custom Highlights API. It works similarly to the browser's native ::selection pseudo element, which controls the visual appearance of text selections. Like ::selection, ::highlight() only supports a limited set of CSS properties: background-color, color, text-decoration, text-shadow, outline and caret-color. Properties such as padding, border-radius, font-weight and display are not allowed and get silently ignored.

This restriction is not a bug, it is a deliberate design choice: the allowed properties can be applied by the browser without having to recalculate the layout. A background-color or a text color does not change the size or position of the element, so the browser can apply it as a pure repaint without triggering a reflow. That also explains why the CSS Custom Highlights API is more performant than span injection with padding and border-radius: there is no reflow from DOM mutation, and the styling itself does not trigger a reflow either. For use cases that require real geometric borders around highlights, such as in code editors, canvas or an overlaid layer remains the only alternative.


/* JavaScript side: using the CSS Custom Highlights API */
/* This code shows the JS integration pattern alongside CSS */

/*
// Feature detection
if (!CSS.highlights) {
  console.warn('CSS Custom Highlights API not supported');
  // Fall back to span injection
}

// Create ranges for all matches of a search term
function highlightText(searchTerm, containerEl) {
  // Clear previous highlights
  CSS.highlights.clear();

  if (!searchTerm) return;

  const ranges = [];

  // Walk all text nodes in the container
  const walker = document.createTreeWalker(
    containerEl,
    NodeFilter.SHOW_TEXT
  );

  let node;
  const term = searchTerm.toLowerCase();

  while ((node = walker.nextNode())) {
    const text = node.textContent.toLowerCase();
    let start = 0;

    while ((start = text.indexOf(term, start)) !== -1) {
      const range = new Range();
      range.setStart(node, start);
      range.setEnd(node, start + term.length);
      ranges.push(range);
      start += term.length;
    }
  }

  // Register highlight: name matches ::highlight(search-result) in CSS
  if (ranges.length > 0) {
    const highlight = new Highlight(...ranges);
    CSS.highlights.set('search-result', highlight);
  }
}
*/

/* CSS receives the highlight name and applies visual styles */
::highlight(search-result) {
  background-color: rgba(196, 181, 253, 0.6);
  color: #2e1065;
}

6. In Practice: A Real Time Search Feature With Custom Highlights

A real time search feature is the most common use case for the CSS Custom Highlights API. On a blog article or documentation page, a search box can be implemented that highlights every occurrence of the search term in the article text on every keystroke, without changing the text, without reloading the page, and without mutating the DOM. The basic pattern: an input event listener on the search field calls a function on every change that traverses every text node in the document, collects matches as ranges, creates a Highlight object, and stores it under the registered name in CSS.highlights.

A more advanced pattern combines two separate Custom Highlights: search-result for all matches, and search-active for the currently focused match. The user can navigate between matches with the arrow keys, and the active match gets a visually stronger style. The search-active highlight always contains exactly one range, which gets updated with the current position. The active match is scrolled into view using range.startContainer.parentElement.scrollIntoView(). The result is a full find-in-document feature, similar to what Ctrl+F offers in the browser, implemented in under 50 lines of JavaScript and two CSS rules.

7. Diff Rendering and Text Comparisons

Another important use case for the CSS Custom Highlights API is rendering text differences, or diffs. Code review tools, document versioning systems, and translation UIs often display two versions of the same text side by side, marking added words in green and removed words in red. With span injection, this is expensive to implement: the diff calculation returns character positions that then have to be translated into DOM mutations, while accounting for HTML tags that already exist in the text.

The CSS Custom Highlights API simplifies this process considerably. The diff library (for example diff-match-patch or the Myers diff algorithm) returns a list of change operations with character positions. These positions are translated directly into Range objects and collected into two Highlight objects, diff-added and diff-removed. Two CSS rules take care of the visual styling. The DOM of the displayed text stays completely unchanged: no text nodes are split, no tags are inserted. That makes the implementation more robust and easier to maintain, especially when the source text already contains HTML markup.

8. Accessibility and Screen Reader Behavior

The CSS Custom Highlights API has a decisive accessibility advantage over span injection: the DOM does not change, so the semantic structure does not change either. A screen reader that reads out the text reads the same text, with or without active highlights. With span injection, on the other hand, inserted <span> elements can interrupt the reading flow, especially when they get inserted in the middle of a word, which easily happens with character offset based matching. A screen reader that reads text nodes one at a time can behave inconsistently when text nodes get split.

The specification of the CSS Custom Highlights API states that ::highlight() styles do not trigger any ARIA properties or accessibility tree changes. That is correct for purely visual highlights like search markers: the user sees the highlight, but it carries no semantic meaning that a screen reader would need to communicate. If highlights are meant to carry semantic meaning, for example "this text contains an error," an ARIA attribute has to be set in addition. That can happen at the element level without mutating the text node: element.setAttribute('aria-description', 'Contains a grammar error').

9. Custom Highlights vs. span Injection Compared

The choice between the CSS Custom Highlights API and span injection largely determines how performant, maintainable, and accessibility compliant a text highlighting implementation is. The differences are measurable across all three dimensions.

Criterion span Injection CSS Custom Highlights API Advantage
DOM Mutation Yes, text nodes get split No, DOM stays unchanged Custom Highlights
Performance Reflow from DOM change Only repaint, no reflow Custom Highlights
Event Listener Stability Lost when text nodes split Stable, no node changes Custom Highlights
Accessibility Can interrupt reading flow DOM structure stays semantic Custom Highlights
CSS Flexibility All CSS properties possible Only limited properties span Injection

The restriction on allowed CSS properties is the only real drawback of the CSS Custom Highlights API. Anyone who needs rounded corners or padding around highlights has to take a different approach. For every other use case, color, underlines, outlines, the CSS Custom Highlights API is the clear winner. Browser support: Chrome 105+, Firefox 117+ and Safari 17.2+. For older browsers, feature detection with if ('highlights' in CSS) and span injection as a fallback is recommended.

Mironsoft

Modern CSS, progressive enhancement, and frontend architecture

Want to implement text features without DOM hacks?

We implement modern browser APIs like the CSS Custom Highlights API in your projects, with feature detection, graceful fallback, and full accessibility compliance.

API Integration

Bringing the Custom Highlights API, View Transitions, and Container Queries into real projects

Accessibility Audit

DOM mutation analysis, ARIA correctness, and screen reader testing

Frontend Architecture

Progressive enhancement, feature detection, and browser compatibility strategy

10. Summary

The CSS Custom Highlights API is the modern, semantically clean answer to the classic problem of highlighting text without DOM mutation. The three tier system, Range objects for text regions, Highlight objects as range containers, and the CSS.highlights registry as a bridge to the stylesheet, enables visual text highlights that add nothing to and take nothing away from the DOM structure. ::highlight(name) in the stylesheet handles the visual styling with a limited but sufficient set of CSS properties for all typical highlight use cases.

The range of use cases is broad: live search features, diff rendering, grammar checking UIs, voice recognition matches, code annotation tools. In every one of these cases, the CSS Custom Highlights API is superior to span injection: in performance (no reflow), in accessibility (stable DOM), in maintainability (no cleanup needed), and in robustness (no event listener loss from DOM mutation). Browser support has existed across all modern browsers since 2023/2024; feature detection and a span fallback cover older environments.

CSS Custom Highlights API: The Essentials at a Glance

Create a Range

new Range(), setStart(textNode, offset), setEnd(textNode, offset). Describes a text region without DOM mutation.

Populate the Registry

new Highlight(...ranges), then CSS.highlights.set('name', highlight). The name matches ::highlight(name) in the stylesheet.

Style With CSS

::highlight(name) { background-color; color; text-decoration; outline }, limited properties, no reflow.

Fallback

if ('highlights' in CSS): use span injection as a fallback for older browsers. Feature detection is mandatory.

11. FAQ: CSS Custom Highlights API

1What is the CSS Custom Highlights API?
Ranges define text regions, a Highlight object collects ranges, CSS.highlights registers them under a name, ::highlight(name) in the CSS styles them. No DOM gets changed.
2Better than span injection?
No DOM reflow, no text node splitting, no event listener loss, no cleanup. A semantically stable DOM for screen readers.
3Which CSS properties in ::highlight()?
background-color, color, text-decoration, text-shadow, outline, caret-color. No padding, border-radius, or font-weight; these get ignored.
4Multiple highlights at once?
Each highlight gets its own name in CSS.highlights. Multiple highlights are active independently of each other.
5Overlapping highlights?
The highlight with the higher priority wins. Set priority on the Highlight object. If priority is equal: the most recently registered one wins.
6Browser support?
Chrome 105+, Edge 105+, Firefox 117+, Safari 17.2+. Feature detection: if ('highlights' in CSS). span fallback for older browsers.
7Removing highlights?
CSS.highlights.clear(), everything. CSS.highlights.delete('name'), a single highlight. highlight.clear(), empty the ranges.
8Custom Highlights in Shadow DOM?
Ranges can point to Shadow DOM text nodes, but ::highlight() styles need to live in the same stylesheet context. Cross-Shadow styling is limited.
9Finding every text occurrence in the DOM?
TreeWalker with SHOW_TEXT traverses every text node. indexOf or regex for matching. Create a Range for every match.
10Custom Highlights in print?
::highlight() styles get applied when printing if background graphics are enabled. For print highlights, use color and text-decoration instead of background-color.