loke.dev
Header image for Highlights are Virtual

Highlights are Virtual

Stop polluting your DOM with thousands of wrapper elements and move to the CSS Custom Highlight API for high-performance, accessible text styling.

· 8 min read

Open your browser’s inspector on a site that implements a "search and highlight" feature using the old-school method. You’ll likely see a DOM tree that looks like a war zone: thousands of <span> tags injected into the text, fragmenting paragraphs into a million tiny nodes. Each of those spans is a heavy DOM element that the browser has to track, style, and calculate layout for. If you’re building a code editor or a high-performance document viewer, this approach eventually hits a wall where the browser simply gives up and drops frames.

The CSS Custom Highlight API changes the game by decoupling the *styling* of text from the *structure* of the DOM. It allows us to paint colors, backgrounds, and underlines onto text ranges without adding a single extra HTML element.

The Cost of DOM-Based Highlighting

Historically, if we wanted to highlight a word, we had to do something like this:

// The "Old Way"
const content = paragraph.innerHTML;
paragraph.innerHTML = content.replace(/target/g, '<span class="hl">target</span>');

This is objectively terrible for several reasons:

1. State Management: If you’re using a framework like React or Vue, manually mangling the innerHTML is a recipe for catastrophic state desync.
2. Accessibility: Splitting a single word into three nodes (before span, span, after span) can break how screen readers announce text or how users select text with their cursor.
3. Layout Thrashing: Every time you add or remove these spans, the browser might re-calculate the geometry of the entire page.
4. Event Listeners: If you have listeners on the parent container, the target changes. Now you're clicking on a <span> instead of the text node you expected.

The CSS Custom Highlight API moves this work to the browser's painting layer. It treats highlights as virtual overlays. The text remains a single, continuous node in the DOM, but the browser is told to "paint this specific range of characters with these styles."

The Three Pillars: Range, Highlight, and Registry

To use this API, you need to coordinate three different pieces. It feels a bit verbose at first, but this separation is what makes it so powerful.

1. Static Range: You define exactly which characters in which nodes should be highlighted.
2. Highlight Object: You group those ranges together into a logical set (e.g., "Search Results" or "Syntax Errors").
3. Highlight Registry: You register that object with a name so CSS can find it.

Let's look at a bare-bones implementation of a search-highlighting feature.

Step 1: Finding the Ranges

First, we need to locate the text we want to style. We do this using the Range API.

const textNode = document.querySelector('#content').firstChild;
const searchTerm = "performance";
const ranges = [];

let startPos = 0;
while ((startPos = textNode.textContent.indexOf(searchTerm, startPos)) !== -1) {
  const range = new Range();
  range.setStart(textNode, startPos);
  range.setEnd(textNode, startPos + searchTerm.length);
  ranges.push(range);
  startPos += searchTerm.length;
}

Step 2: Registering the Highlight

Once we have our array of ranges, we create a Highlight object and add it to the global CSS.highlights registry.

// Create the highlight object
const searchHighlight = new Highlight(...ranges);

// Register it with the name 'search-results'
CSS.highlights.set("search-results", searchHighlight);

Step 3: Styling in CSS

Now, in your CSS file, you use the ::highlight() pseudo-element. This is where the magic happens.

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

The browser now paints that gold background over those specific ranges. If you inspect the DOM, you’ll see the original, clean text node. No spans. No mess.

Performance: Why "Virtual" Wins

When you use <span> tags, the browser's engine has to treat those spans as "Boxes." Even if they are inline, they have a place in the layout tree. If you have 5,000 search results in a long document, you’ve just added 5,000 objects to the layout engine.

The Custom Highlight API is layout-neutral. Because ::highlight only supports properties that affect the paint phase (like color, background-color, text-decoration, text-shadow), the browser knows for a fact that applying a highlight will never change the width or height of an element. It doesn't need to re-run the expensive layout calculations. It just swaps the pixel colors during the paint step.

I’ve seen apps that used to lag during a "find-in-page" search suddenly become buttery smooth after switching to this API. It’s the difference between re-rendering a whole component and just toggling a GPU-accelerated layer.

Handling Dynamic Updates

One of the best parts about the Highlight object is that it's a Set. This means you can add and remove ranges dynamically, and the browser will automatically update the view.

Imagine a user is typing in a search box. You don't want to clear the registry and re-register every time. You just update the ranges.

const searchInput = document.querySelector('#search-input');
const contentNode = document.querySelector('#article-body').firstChild;

// Initialize once
const searchHighlight = new Highlight();
CSS.highlights.set("search-match", searchHighlight);

searchInput.addEventListener('input', (e) => {
  // Clear previous results efficiently
  searchHighlight.clear();

  const query = e.target.value.trim();
  if (query.length < 3) return;

  const text = contentNode.textContent.toLowerCase();
  let index = text.indexOf(query.toLowerCase());

  while (index !== -1) {
    const range = new Range();
    range.setStart(contentNode, index);
    range.setEnd(contentNode, index + query.length);
    searchHighlight.add(range);
    
    index = text.indexOf(query.toLowerCase(), index + query.length);
  }
});

Because Highlight inherits from the native Set class, adding a range is an $O(1)$ operation. The browser is optimized to listen for changes to this set and re-paint only the affected regions.

The Priority System: When Highlights Overlap

In a real-world application, you might have multiple layers of highlights. You might have a "search" highlight (yellow) and a "user selection" highlight (blue) and maybe a "syntax error" highlight (red wavy underline).

What happens when a word is both a search result and a syntax error?

The Highlight object has a .priority property. Higher numbers win.

const searchResults = new Highlight(...searchRanges);
searchResults.priority = 10;

const syntaxErrors = new Highlight(...errorRanges);
syntaxErrors.priority = 20; // This will paint over the search result if they overlap

CSS.highlights.set("search", searchResults);
CSS.highlights.set("errors", syntaxErrors);

If the priorities are equal, the order in which they were added to the CSS.highlights registry determines the winner (the last one set wins). This gives you granular control over the "stacking context" of your virtual highlights without fighting with z-index.

Limitations and the "Gotchas"

It’s not all sunshine and perfect performance. There are a few things that tripped me up when I first started using this.

1. Limited CSS Properties

You cannot use any CSS property that would change the geometry of the text. Forget about padding, margin, border, or display. You are limited to:
* color
* background-color
* text-decoration
* text-shadow
* -webkit-text-stroke

If you try to use font-weight: bold, it simply won't work. Bold text changes the width of the characters, which would require a layout reflow. The API is specifically designed to avoid this.

2. Interaction

Since highlights are virtual, they are not "real" elements in the eyes of the Pointer Events API. You cannot attach an onclick listener to a highlight.

If you need to make a highlight clickable (like a "jump to definition" link), you still have to calculate the click coordinates and compare them to the range's bounding box using range.getBoundingClientRect(). It’s more manual, but it’s the price you pay for the performance gains.

3. Text Iteration

The code examples above assume a single text node. In reality, your content is likely spread across multiple <div>, <p>, and <span> tags. To highlight a search term that spans across a <strong> tag (e.g., He**llo Wor**ld), you have to create multiple ranges or use the TreeWalker API to find all the text nodes and calculate offsets correctly.

It gets complicated quickly. Here is a helper function pattern I use for multi-node searching:

function* findTextNodes(root) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  let node;
  while ((node = walker.nextNode())) {
    yield node;
  }
}

function highlightText(container, query) {
  const highlight = new Highlight();
  for (const node of findTextNodes(container)) {
    let start = 0;
    const text = node.textContent.toLowerCase();
    while ((start = text.indexOf(query.toLowerCase(), start)) !== -1) {
      const range = new Range();
      range.setStart(node, start);
      range.setEnd(node, start + query.length);
      highlight.add(range);
      start += query.length;
    }
  }
  CSS.highlights.set("global-search", highlight);
}

Accessibility Considerations

There is a subtle nuance to accessibility with this API. When you wrap text in a <mark> or <span>, you are technically modifying the accessibility tree. Browsers and screen readers are getting better at handling ::highlight, but it doesn't automatically convey "meaning" the same way an HTML element might.

If the highlight is critical to understanding the document (like marking "Required" fields or "Deletions"), you should still use semantic HTML. If the highlight is purely functional or a visual aid (like search results or syntax highlighting), the Custom Highlight API is the superior choice.

Always ask: *Does the user need to know this text is styled to understand the content?* If yes, use a tag. If no (it's just a visual helper), use the API.

Real-World Use Case: A "Live" Code Linter

Let’s look at a more complex example. We want to highlight specific words (like "TODO" or "FIXME") in a textarea or a contenteditable div as the user types.

const editor = document.querySelector('#editor');
const todoHighlight = new Highlight();
CSS.highlights.set('todo-items', todoHighlight);

function updateLinting() {
  todoHighlight.clear();
  const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
  let node;
  const regex = /\b(TODO|FIXME)\b/g;

  while (node = walker.nextNode()) {
    const text = node.textContent;
    let match;
    while ((match = regex.exec(text)) !== null) {
      const range = new Range();
      range.setStart(node, match.index);
      range.setEnd(node, match.index + match[0].length);
      todoHighlight.add(range);
    }
  }
}

editor.addEventListener('input', updateLinting);
::highlight(todo-items) {
  background-color: #ff4444;
  color: white;
  font-weight: normal; /* Remember: bold won't work! */
  text-decoration: wavy underline yellow;
}

This setup is incredibly lightweight. Even as the document grows to tens of thousands of words, the "linting" layer stays entirely virtual. The DOM remains a clean set of text nodes, and the browser handles the visual overlay efficiently.

Browser Support and Polyfills

As of late 2023 and early 2024, support is quite strong across the board. Chrome, Edge, and Safari have full support. Firefox has recently added support behind a flag and is moving toward full release.

For a production app, you might want a fallback strategy:

if (!CSS.highlights) {
  // Fallback to the slow span-injection method or 
  // simply skip the highlighting for a better UX than a broken one.
  console.warn("Custom Highlight API not supported. Falling back.");
}

If you are building a tool where performance is a feature—like a log viewer, a code editor, or a heavy-duty data table—the CSS Custom Highlight API isn't just a "nice to have." It's the only way to keep the DOM manageable while providing a rich, interactive visual experience.

Stop thinking in spans. Start thinking in ranges. Your browser (and your users' CPUs) will thank you.