Back to notes

CSS Custom Highlight API: style text without spans

21 October 2026·
cssjavascripthighlightwebdev
Live demoOpen full screen

You can paint any piece of text on a page without wrapping it in a <span> or a <mark>: the CSS Custom Highlight API has been Baseline since March 2026, when Firefox 149 shipped the last missing piece, the ::highlight() pseudo-element.

Type something in the demo above. Every match turns orange, but keep an eye on the middle counter: the number of DOM nodes inside the text doesn't move. Start the read-along or flag the typos and it still doesn't move. In a browser without the API, the demo falls back to real <mark> elements and you can watch that counter grow and turn orange.

Why this matters

The classic way to highlight search results is to rewrite the HTML: split text nodes, wrap the matches in elements, and undo it all on the next keystroke. It means extra elements in the DOM on every keystroke, and it gets messy fast when two highlights need to overlap.

MDN describes the API as a way to style arbitrary text ranges by "using JavaScript to create the ranges, and CSS to style them". It extends the idea behind the highlight pseudo-elements the browser already had (::selection, ::spelling-error, ::grammar-error, ::target-text) to ranges you define yourself. Typical uses: search in page, spell and grammar checkers, code editors, collaborative editors with one colour per user.

How it works

Four steps: create Range objects, group them in a Highlight, register it by name in CSS.highlights, and style that name with ::highlight().

const p = document.querySelector('p');
const text = p.firstChild; // a text node

// 1. A Range for every match
const ranges = [];
let i = text.textContent.indexOf('ranges');
while (i !== -1) {
  const range = new Range();
  range.setStart(text, i);
  range.setEnd(text, i + 'ranges'.length);
  ranges.push(range);
  i = text.textContent.indexOf('ranges', i + 1);
}

// 2 and 3. One Highlight holds them all; the registry gives it a name
CSS.highlights.set('search', new Highlight(...ranges));
/* 4. Only the name is shared between JS and CSS */
::highlight(search) {
  background-color: #FF5200;
  color: white;
}

That's the whole trick in the demo's search box: on each input event it walks the text nodes with a TreeWalker, builds the ranges and calls CSS.highlights.set() again. CSS.highlights is a Map-like registry, so delete(name) and clear() remove highlights, and a Highlight is Set-like, with add(), delete() and size.

The read-along button uses the same idea with a single range that moves to the next word every 280 ms. The typos button registers a third highlight and sets its type:

const typos = new Highlight(...typoRanges);
typos.type = 'spelling-error'; // default is 'highlight'; 'grammar-error' also exists
CSS.highlights.set('typo', typos);

According to MDN, type lets assistive technologies include that meaning when they expose the highlight, although support "may vary across platforms".

When highlights overlap

In the demo the search can overlap the read-along word. Which colour wins is not decided by CSS order or !important: it's decided by Highlight.priority. With equal priorities, the most recently registered highlight wins for the conflicting properties. The demo gives the search priority = 1, so orange stays on top. Properties that don't conflict are all applied.

What you can style

::highlight() only accepts a short list of properties: color, background-color, text-decoration and its longhands, text-shadow, and -webkit-text-stroke-color, -webkit-text-fill-color and -webkit-text-stroke-width. background-image is ignored, so no gradients. The wavy red underline in the demo is plain text-decoration: underline wavy.

Browser support and gotchas

  • Support: Chrome and Edge 105, Safari 17.2, Firefox 149 for the full feature. Firefox had the JavaScript side (Highlight, CSS.highlights) from 140, but ::highlight() couldn't use text-decoration before 146 or text-shadow before 149.
  • Detect it: 'highlights' in CSS is the basic check. The demo checks that and typeof Highlight === 'function', shows a warning and falls back to <mark> elements.
  • Safari and user-select: none: browser-compat-data notes that in Safari the highlight style is ignored when combined with user-select: none (WebKit bug 278455). Don't put highlights on text you've made unselectable.
  • No semantics by default: MDN is clear that custom highlights don't add semantic meaning to the document; type only covers spelling and grammar errors, and support for it varies. If the highlight carries meaning a screen reader user needs, MDN suggests semantic HTML such as <mark> or another accessible cue.
  • Ranges point at text nodes. If your framework re-renders and replaces those nodes, recompute the ranges. The demo's fallback does exactly that after each reset.

What to keep an eye on

  • Older Firefox: until your audience is on 149+, test text-shadow and text-decoration there, or stick to color and background-color.
  • Accessibility: use type where it fits, and don't rely on colour alone for meaning.
  • Editors and search: in-page search, spell checking and collaborative cursors are the obvious places where this replaces a lot of DOM juggling.

Sources