Highlight Search Terms In Page Content
When users type into a live search input on a web page, they expect the term they entered to be visible in the search results. The expected UI should look something like this:
How can you add this feature to an existing application? The solution is quite straightforward once you know where to look.
Highlight Search Term
The TL;DR is to use highlight-search-term
, an open-source library that I recently published. It highlights the search term in elements matching a given CSS selector:
import { highlightSearchTerm } from 'highlight-search-term';
// ...
highlightSearchTerm({ search: search.value, selector: ".content" });
You can change how the highlighted text is styled with CSS:
::highlight(search) {
background-color: yellow;
color: black;
}
highlight-search-term
is a vanilla JS library that doesn't update the DOM. This means it can highlight text in contentEditable
elements, as well as in apps where the DOM is controlled by a frontend framework like React, Vue, and Angular. For instance, to highlight a search term in a React application, call highlightSearchTerm()
in a React useEffect
:
import { useEffect, useState } from "react";
import { highlightSearchTerm } from "highlight-search-term";
export default function App() {
const [search, setSearch] = useState("");
useEffect(() => {
highlightSearchTerm({ search, selector: ".content" });
}, [search]);
return (
<div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="content">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
</div>
</div>
);
}
How does this library work? To answer this question, we must first understand the solutions that don't work.
Using a Wrapper Element
The most common solution for this use case is to use a wrapper element, like a <mark>
or a <span>
. That's what libraries like react-highlight-words
do.
So, for instance, if you search for the string "the" in the following text:
The dog is chasing the cat. Or perhaps they're just playing?
The react-highlight-words
library will alter the text as follows:
<mark>The</mark> dog is chasing <mark>the</mark> cat. Or perhaps <mark>the</mark>y're just playing?
This library introduces a <Highlighter>
element for this task:
import React from "react";
import { createRoot } from "react-dom/client";
import Highlighter from "react-highlight-words";
const root = createRoot(document.getElementById("root"));
root.render(
<Highlighter
highlightClassName="YourHighlightClass"
searchWords={["and", "or", "the"]}
autoEscape={true}
textToHighlight="The dog is chasing the cat. Or perhaps they're just playing?"
/>
);
But this approach has three drawbacks:
- It only works for text nodes, so you can't use it on a tree of nodes. For instance, if you want to highlight a search term in a rendered Markdown string, you're out of luck.
- Since it's modifying the DOM, you can't use it in a
contentEditable
element, or its content will be corrupted. - React rerenders the text each time the search term changes. On long pieces of content and with short search terms like "the" this is slow and the UI may lag, especially on slow devices.
Perhaps there is a better way?
Using Text Fragments
Did you know that browsers can highlight a search term themselves? Google sometimes uses this feature to scroll the relevant section of a page into view when you click on a search result.
This works using a custom URL hash tag:
This feature, called Text Fragments, is a W3C standard and is supported across all evergreen browsers except Firefox. To enable it, append the #:~:text=SEARCH_TERM
hashtag to the URL of any web page.
Couldn't we use Text Fragments to highlight text in a live search?
Unfortunately, no, because Text Fragments have an important limitation. The MDN documentation explains it:
So this solution doesn't work for live search, as the highlight change is initiated by JavaScript. Now, let's see a solution that actually works.
Using CSS Highlights
It turns out that highlighting text fragments without modifying the DOM is such a common task that browsers provide a native JS API to do it. It's called the CSS Custom Highlight API, and it involves three JavaScript classes (Range
, Highlight
, and CSS
). It basically works like this:
const contentNode = document.getElementById("content");
// Create a Range object around each fragment that must be highlighted
// (the same type of Range used for reading the currently selected text)
const range1 = new Range();
range1.setStart(contentNode, 10);
range1.setEnd(contentNode, 20);
const range2 = new Range();
range2.setStart(contentNode, 40);
range2.setEnd(contentNode, 60);
// Add the Range objects to a new Highlight object
const highlight = new Highlight(range1, range2);
// Add the Highlight to the browser's Highlight Registry and give it a name
CSS.highlights.set("search", highlight);
Now you can style this highlight in CSS with the ::highlight()
pseudo-element.
::highlight(search) {
background-color: yellow;
color: black;
}
Just like Text Fragments, the CSS Custom Highlight API is currently not supported by Firefox, but that will soon change (it's currently in nightly, and should be released in Firefox 126).
That's the method that highlight-search-term
uses. The main difficulty is to create the ranges because the Range
API is a bit tricky. You can only create a Range
in a text node, so the library has to recursively walk the DOM tree until it finds text nodes. Here is the full source code:
const highlightSearchTerm = ({
search,
selector,
customHighlightName = "search",
}) => {
if (!selector) {
throw new Error("The selector argument is required");
}
if (!CSS.highlights) return; // disable feature on Firefox as it does not support CSS Custom Highlight API
// remove previous highlight
CSS.highlights.delete(customHighlightName);
if (!search) {
// nothing to highlight
return;
}
// find all text nodes containing the search term
const ranges = [];
const elements = document.querySelectorAll(selector);
Array.from(elements).map((element) => {
getTextNodesInElementContainingText(element, search).forEach((node) => {
ranges.push(
...getRangesForSearchTermInElement(node.parentElement, search)
);
});
});
if (ranges.length === 0) return;
// create a CSS highlight that can be styled with the ::highlight(search) pseudo-element
const highlight = new Highlight(...ranges);
CSS.highlights.set(customHighlightName, highlight);
};
const getTextNodesInElementContainingText = (element, text) => {
const nodes = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (node.textContent?.toLowerCase().includes(text)) {
nodes.push(node);
}
}
return nodes;
};
const getRangesForSearchTermInElement = (element, search) => {
const ranges = [];
if (!element.firstChild) return ranges;
const text = element.textContent?.toLowerCase() || "";
let start = 0;
let index;
while ((index = text.indexOf(search, start)) >= 0) {
const range = new Range();
range.setStart(element.firstChild, index);
range.setEnd(element.firstChild, index + search.length);
ranges.push(range);
start = index + search.length;
}
return ranges;
};
As this approach doesn't modify the DOM, it can be safely used in declarative frameworks like React and Angular, and in contentEditable
elements. The only limitation is that it doesn't allow highlighting text in a textarea
element, but that's a browser limitation that no JS or CSS technique can overcome.
Closing Words
I couldn't find an easy way to use the CSS Custom Highlight API for this use case, so I wrote highlight-search-term
, and published it under the MIT License. You can use it in any JS app:
npm install highlight-search-term
Check its documentation at marmelab/highlight-search-term on GitHub.
If you're looking for the application that was used for the screencast at the beginning of this article, it's called Writer's Delight, and it's a free note-taking app with AI completion features, powered by react-admin.