When you want to highlight specific characters, words, or passages on a website or web app, a common approach has been to wrap the target text in a <span> tag or similar element and apply CSS styles to it.
This approach requires tags that are unrelated to the structure of the text itself, which can make the HTML hierarchy unnecessarily deep. For dynamic highlights, JavaScript also needs to rewrite the DOM and insert tags, making it harder to keep structure and presentation separate.
The CSS Custom Highlight API is a browser API that lets you highlight parts of text without rewriting the DOM. This article explains how to use it and introduces demos that use the API.
Basic usage
First, let’s cover the basics.
1. Create a Range object in JavaScript
Get the DOM node that contains the text you want to highlight. Then create a Range object and specify the highlight range with the setStart() and setEnd() methods.
<p class="description">CSS Custom Highlight API is for highlighting texts!</p>
const description = document.querySelector(".description");
const range = new Range();
// By passing firstChild, register the range of the text node
// inside description.
range.setStart(description.firstChild, 0);
range.setEnd(description.firstChild, 24);
The key point is that firstChild, which is the text node, is passed as the first argument to the setStart() and setEnd() methods.
When a text node is passed as the first argument to setStart() and setEnd(), the second argument specifies a character offset, or position. In the example above, the range is set from character 0 to character 24.
If you pass the description object itself instead, the second argument is interpreted as the index of a child node. Since description has only one child node, the text node, the index is out of range and an error occurs.
// This is invalid. It throws an IndexSizeError.
range.setStart(description, 0);
range.setEnd(description, 24);
2. Create and register a Highlight object
Create a Highlight object. Pass the range you want to highlight as its argument.
Register the created object in highlights on the CSS interface. The first argument is the keyword used in CSS. In this example, the keyword is "basic-highlight".
// Create a Highlight object.
const basicHighlight = new Highlight(range);
// Set the keyword used in CSS as the first argument.
CSS.highlights.set("basic-highlight", basicHighlight);
3. Set the styles
In CSS, apply styles with the ::highlight() pseudo-element. The "basic-highlight" keyword registered above is used here.
/* Specify the "basic-highlight" keyword registered in JS. */
::highlight(basic-highlight) {
background: yellow;
}
That is all you need. Check the result in the browser, and the “CSS Custom Highlight API” part should be highlighted in yellow.
Advantages of the CSS Custom Highlight API
Now that the basic usage is clear, let’s summarize the advantages.
Highlight text without adding markup
As mentioned at the beginning, the biggest advantage is that styles can be applied without using <span> tags or similar markup.
This makes it possible to do things such as:
- dynamically highlighting text in response to user actions
- displaying strings received from a server as-is, then highlighting arbitrary text after rendering
Show multiple highlights at the same time
In the basic example, an arbitrary keyword was set with the CSS.highlights.set() method. You can define multiple styles flexibly by keyword, such as changing the highlight color, adding an underline, or changing the text color.
::highlight(highlight) {
background: yellow;
}
::highlight(underline) {
text-decoration: underline;
color: red;
}
Demos
The following demos use the CSS Custom Highlight API.
Demo 1: search term highlighting
This demo highlights text that matches the input field at the top of the screen in real time. It is implemented with the following steps.
- Get the text nodes when the page loads
- Register a highlight function for the input field’s
inputevent - Create ranges that match the search term
- Highlight the matching parts
// 1. Get the text nodes when the page loads.
const textNodes = [];
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
let currentNode = walker.nextNode();
while (currentNode !== null) {
textNodes.push(currentNode);
currentNode = walker.nextNode();
}
/**
* Update the highlight.
*/
const updateHighlight = () => {
const keyword = searchInput.value;
// Omitted
const ranges = [];
const regex = new RegExp(RegExp.escape(keyword), "gi");
// Loop through the text nodes.
for (const textNode of textNodes) {
for (const match of textNode.textContent.matchAll(regex)) {
// 3. Create a range that matches the keyword.
const range = new Range();
range.setStart(textNode, match.index);
range.setEnd(textNode, match.index + match[0].length);
ranges.push(range);
}
}
// 4. Highlight the parts that match the keyword.
CSS.highlights.set("search", new Highlight(...ranges));
}
// 2. Register the highlight function for the input event.
searchInput.addEventListener("input", updateHighlight);
Demo 2: proofread input as it is typed
This demo highlights violations of proofreading rules in text entered by the user. When the input area changes, the violating parts are highlighted in the preview area at the bottom of the screen.
The implementation is almost the same as Demo 1.
- Register a function for the input field’s
inputevent - Create ranges that match the rules
- Highlight the matching parts
/**
* Update the preview.
*/
const render = () => {
// Omitted
const ranges = [];
for (const rule of rules) {
for (const matchResult of targetText.matchAll(rule.pattern)) {
const matchedText = matchResult[0];
if (matchedText.length === 0) continue;
// 2. Create a range that matches the rule.
const range = new Range();
range.setStart(previewTextNode, matchResult.index);
range.setEnd(previewTextNode, matchResult.index + matchedText.length);
ranges.push(range);
}
}
// Omitted
// 3. Highlight the parts that match a rule.
CSS.highlights.set("correction", new Highlight(...ranges));
}
// 1. Watch input events and update the preview.
editor.addEventListener("input", render);
Demo 3: AI-powered sentiment heatmap highlights for social media posts
In this demo, AI evaluates fictional movie reviews, determines whether parts of the text express negative or positive sentiment, and highlights those parts.
The AI-related part uses a library called Mastra. This article does not cover Mastra in detail, but if you are interested, see “Mastra入門 - TypeScriptで作るAIエージェント”.
Although AI is involved, the highlighting process itself is the same as in Demo 1 and Demo 2. The demo sets the ranges to highlight, then applies the corresponding highlight color based on the negative or positive sentiment level.
Column: what happens when you pass a list element to Range.setStart() and setEnd()?
In the demos, the firstChild text node of a DOM element obtained with querySelector() or a similar method was passed to the setStart() and setEnd() methods. These methods accept a Node, so you can also pass the <ul> element itself as shown below. What happens to the highlight in that case?
<ul class="list">
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>
const list = document.querySelector(".list");
const listRange = new Range();
// What happens if the list itself is passed and 0 to 2 is set?
listRange.setStart(list, 0);
listRange.setEnd(list, 2);
In the example above, list has the following structure. When the <ul> element itself is passed, the range from node 0 to node 2 is registered, so “Apple” is highlighted.
- index 0: newline and spaces between the
ulandlitags - index 1:
<li>element node,<li>Apple</li> - index 2: newline and spaces between the
liandlitags - index 3:
<li>element node,<li>Banana</li> - index 4: newline and spaces between the
liandlitags - index 5:
<li>element node,<li>Cherry</li> - index 6: newline between the
liandultags
The important point is that the newline and spaces are counted as one node. Even if the DOM structure is the same, the highlighted range changes when line breaks and spaces are removed, as shown below.
<ul class="list"><li>Apple</li><li>Banana</li><li>Cherry</li></ul>
In fact, in the version where the spaces and line breaks between the <ul> and <li> elements are removed, both “Apple” and “Banana” are highlighted.
HTML files built with build tools such as Vite may be minified, removing line breaks and spaces. In this way, setting a non-text node on a Range can produce unintended results.
That said, in practical use cases, you will probably use text nodes most of the time, so this difference in behavior should not be a major issue.
Browser support
The CSS Custom Highlight API is available in Chrome and Edge 105, released in August 2022, Safari 17.2, released in December 2023, and Firefox 149, released in March 2026, or later.
Reference: Can I use…
Conclusion
This article introduced the CSS Custom Highlight API. It is a simple API, but it can be combined with many different use cases and is practical to work with. Try it out.

