Appearance
Text Highlighting
When to use
Use this technique when you need to visually highlight text ranges — search matches, syntax tokens, annotations, spelling errors — without modifying DOM structure. Traditional approaches wrap text in <span> elements, which causes DOM bloat, breaks accessibility (screen readers see fragmented text nodes), and degrades performance in large documents. The CSS Custom Highlight API paints ranges directly via the rendering engine, leaving the DOM untouched.
The pattern
Three steps: create ranges over text nodes, register them as a named highlight, and style with the ::highlight() pseudo-element.
1. Create ranges
Identify the text node and character offsets, then create Range objects:
javascript
const range = new Range();
range.setStart(textNode, startOffset);
range.setEnd(textNode, endOffset);For multiple matches (e.g. all occurrences of a search term), create one range per match:
javascript
function findRanges(textNode, query) {
const text = textNode.textContent;
const ranges = [];
let index = text.indexOf(query);
while (index !== -1) {
const range = new Range();
range.setStart(textNode, index);
range.setEnd(textNode, index + query.length);
ranges.push(range);
index = text.indexOf(query, index + query.length);
}
return ranges;
}2. Register a highlight
Wrap the ranges in a Highlight object and register it on the highlight registry under a name:
javascript
const ranges = findRanges(textNode, "match");
const highlight = new Highlight(...ranges);
CSS.highlights.set("search-match", highlight);The name passed to CSS.highlights.set() is what you reference in CSS. You can register multiple named highlights simultaneously (e.g. "search-match", "syntax-keyword", "spell-error").
3. Style with ::highlight()
css
::highlight(search-match) {
background-color: #fef08a;
color: black;
}
::highlight(syntax-keyword) {
color: #7c3aed;
}
::highlight(spell-error) {
text-decoration: wavy underline red;
}The ::highlight() pseudo-element supports a limited set of properties: color, background-color, text-decoration, text-shadow, and -webkit-text-stroke.
Updating highlights
When content changes, update ranges in place rather than tearing down and recreating the entire highlight:
javascript
// Update an existing range to cover new offsets
range.setStart(textNode, newStart);
range.setEnd(textNode, newEnd);
// Add a new range to an existing highlight
highlight.add(newRange);
// Remove a range that no longer applies
highlight.delete(oldRange);The Highlight object is a Set-like collection — use add(), delete(), and clear() to manage its ranges without re-registering the highlight.
Complete example: live search highlighting
javascript
const content = document.querySelector(".content");
const input = document.querySelector(".search-input");
const highlight = new Highlight();
CSS.highlights.set("search", highlight);
input.addEventListener("input", () => {
highlight.clear();
const query = input.value.trim();
if (!query) return;
const walker = document.createTreeWalker(
content, NodeFilter.SHOW_TEXT,
);
while (walker.nextNode()) {
const node = walker.currentNode;
const text = node.textContent;
let index = text.indexOf(query);
while (index !== -1) {
const range = new Range();
range.setStart(node, index);
range.setEnd(node, index + query.length);
highlight.add(range);
index = text.indexOf(query, index + query.length);
}
}
});css
::highlight(search) {
background: #fef08a;
}Don't: DOM-based highlighting
Avoid replacing innerHTML to inject highlight spans — this destroys event listeners, breaks accessibility, and forces a full re-render:
javascript
// ❌ DOM-based — causes bloat and breaks the document
text.innerHTML = text.textContent.replace(
/match/g,
`<span class="highlight">match</span>`,
);Use cases
- Search matches — highlight query terms without DOM modification
- Syntax highlighting — paint tokens in code blocks performantly
- Spell/grammar check — underline errors with custom styles
- Diff visualization — color added/removed text segments
- Reading guides — highlight the active line or sentence
- Coreference — highlight all mentions of an entity together
- Karaoke/lyrics — time-synced word highlighting
Checks
- No
<span>elements added for highlighting purposes - Highlights registered via
CSS.highlights.set() - Styled using
::highlight()pseudo-element - Ranges updated (not recreated) when content changes
- Browser support: Chrome 105+, Safari 17.2+, Firefox 140+ (partial)
Trade-offs
- Browser support — Chrome 105+, Edge 105+, Safari 17.2+, and Firefox 140+ (partial —
text-shadowandtext-decorationmay have limitations). Older browsers need a fallback strategy (DOM-based spans or no highlighting). - Limited stylable properties —
::highlight()only supportscolor,background-color,text-decoration,text-shadow, and-webkit-text-stroke. You cannot setborder,padding,border-radius, or other box-model properties on highlighted ranges. - Text nodes only — the API operates on text node offsets, not element boundaries. If your content has complex nested markup, walking the tree to find the right text nodes and offsets adds complexity.
- No built-in persistence — highlights exist only in memory. If you need highlights to survive page reloads, you must serialize the range offsets and recreate them on load.