Highlighting Text and Elements on a Page
Highlight search matches and page elements from an MV3 content script without breaking the page: the CSS Custom Highlight API, outline overlays, mark-element fallbacks, scrolling to matches and cleanup.
Table of Contents
The extension needs to mark things on the page: every occurrence of a keyword, the paragraph a saved quote came from, the elements a rule would hide, the price on a product page. The classic approach wraps matches in <mark> elements or adds a class to target elements — and it breaks things. React re-renders and throws away your marks, or worse, throws an error because its DOM no longer matches its virtual DOM. Event listeners attached to text nodes stop firing. Copy-and-paste picks up your markup. Highlighting is one of the clearest cases where modifying the page’s DOM is the wrong default. This guide shows the alternatives. It belongs to in-page overlays and injected UI.
Two kinds of highlight, two techniques
Highlighting text — a word or phrase inside a paragraph — is best done with the CSS Custom Highlight API: you create Range objects over the page’s existing text nodes, register them under a name, and a ::highlight(name) CSS rule paints them. No element is inserted, so frameworks, selection and copy are unaffected. Highlighting elements — a whole image, a card, a form field — is best done with an overlay: a positioned box in your own shadow root that tracks the target’s bounding rectangle. The page’s element is never touched. Wrapping text in <mark> remains as a fallback for engines without the highlight API, used carefully and removed promptly.
Step-by-step: non-destructive highlights
1. Find matches as ranges
1// content script
2function findRanges(term, root = document.body) {
3 const needle = term.toLocaleLowerCase();
4 const ranges = [];
5 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
6 acceptNode(n) {
7 const p = n.parentElement;
8 if (!p || p.closest("script, style, noscript, textarea, [contenteditable]")) return NodeFilter.FILTER_REJECT;
9 return NodeFilter.FILTER_ACCEPT;
10 },
11 });
12 for (let node = walker.nextNode(); node; node = walker.nextNode()) {
13 const text = node.data.toLocaleLowerCase();
14 for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
15 const r = new Range();
16 r.setStart(node, i);
17 r.setEnd(node, i + needle.length);
18 ranges.push(r);
19 }
20 }
21 return ranges;
22}
Execution context: the content script in the isolated world, which sees the same DOM as the page. Skipping scripts, styles and editable regions avoids painting inside code and avoids interfering with what the user is typing. This simple matcher misses phrases split across nodes (for example <b>Mani</b>fest); for that, concatenate text across inline siblings and map offsets back, or accept the limitation for single-word search.
2. Paint them with the Custom Highlight API
1const HIGHLIGHT = "readable-match";
2
3export function highlight(term) {
4 const ranges = findRanges(term);
5 if ("highlights" in CSS) {
6 CSS.highlights.set(HIGHLIGHT, new Highlight(...ranges));
7 } else {
8 wrapWithMarks(ranges); // fallback, step 5
9 }
10 return ranges.length;
11}
1// sw.js — inject the paint rule into the page's document once
2await chrome.scripting.insertCSS({
3 target: { tabId },
4 css: `::highlight(readable-match) { background-color: #fde047; color: #111827; }`,
5});
Execution context: the content script registers the highlight; the service worker injects the CSS rule, because ::highlight() must be declared in a stylesheet applying to the page document — a rule inside your shadow root would not reach page text. The highlight name is your namespace: prefix it to avoid colliding with the page or another extension. Only a few properties are allowed in ::highlight() (colours, text decorations, shadows); layout properties are ignored.
3. Highlight elements with tracked overlays
1// root is your closed shadow root from the overlay host
2export function outlineElements(root, elements) {
3 const layer = root.querySelector(".layer") ?? root.appendChild(Object.assign(document.createElement("div"), { className: "layer" }));
4 layer.replaceChildren(...elements.map(() => Object.assign(document.createElement("div"), { className: "box" })));
5 const place = () => elements.forEach((el, i) => {
6 const r = el.getBoundingClientRect();
7 Object.assign(layer.children[i].style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
8 });
9 place();
10 addEventListener("scroll", place, { passive: true, capture: true, signal });
11 addEventListener("resize", place, { passive: true, signal });
12}
Execution context: the content script, drawing into its own shadow root with .layer { position: fixed; inset: 0; pointer-events: none; } and .box { position: fixed; outline: 3px solid #2563eb; border-radius: 4px; }. pointer-events: none lets clicks pass through to the page. Listening for scroll in the capture phase catches scrolling inside nested containers too. signal is the feature’s AbortSignal, so teardown removes the listeners. For many boxes, batch updates in requestAnimationFrame.
4. Scroll matches into view and step through them
1let current = -1;
2export function next(ranges) {
3 if (ranges.length === 0) return;
4 current = (current + 1) % ranges.length;
5 const r = ranges[current];
6 CSS.highlights.set("readable-current", new Highlight(r));
7 r.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
8}
Execution context: the content script. A second highlight name for the current match lets you style it differently (::highlight(readable-current) { background-color: #f97316; }). Respecting reduced-motion preferences avoids smooth-scrolling for users who have asked the OS to minimise motion.
5. Fall back to mark elements carefully
1function wrapWithMarks(ranges) {
2 for (const r of ranges.reverse()) { // reverse so offsets stay valid
3 const mark = document.createElement("mark");
4 mark.dataset.readable = "";
5 r.surroundContents(mark);
6 }
7}
8
9function unwrapMarks() {
10 for (const m of document.querySelectorAll("mark[data-readable]")) {
11 m.replaceWith(...m.childNodes);
12 m.parentNode?.normalize();
13 }
14}
Execution context: the content script, only when CSS.highlights is unavailable. Process ranges in reverse document order so earlier offsets are not shifted by later insertions. normalize() merges the split text nodes back together when unwrapping. Expect frameworks to fight this approach; remove marks before the user interacts with the page, and avoid it entirely on pages driven by React or Vue.
6. Clear everything
1export function clearHighlights() {
2 CSS.highlights?.delete("readable-match");
3 CSS.highlights?.delete("readable-current");
4 unwrapMarks();
5}
Execution context: the content script, called from the feature’s teardown, when the search term changes, and when the extension context is invalidated. Leaving a stale Highlight registered after the content script’s context is gone keeps the paint on the page until reload, because the registry belongs to the page’s document.
Common mistakes
- Putting
::highlight()inside the shadow root. It must be in a stylesheet for the page document; inject it withinsertCSS. - Rebuilding ranges on every keystroke. Walking a long page’s text on each input event is slow. Debounce the search input.
- Keeping ranges across re-renders. When a framework replaces nodes, ranges collapse silently. Re-run the search after significant DOM mutations.
- Wrapping text in
<mark>on framework pages. It can crash the page’s own rendering. Use the highlight API or skip highlighting. - Overlay boxes that intercept clicks. Forgetting
pointer-events: noneturns your highlight into a wall over the page.
Cross-browser variation
- Chrome / Edge: Custom Highlight API from 105;
insertCSSfor the paint rule. - Firefox: the Custom Highlight API arrived in recent versions; feature-detect
CSS.highlightsand use overlays or marks on older releases. - Safari: supports the highlight API from 17.2. On iOS, overlays positioned with
position: fixedbehave differently while the dynamic toolbar collapses; recompute onresize.
Verification
- Highlight a word on a long article and confirm matches are painted while DevTools’ Elements panel shows no new elements in the article.
- Select and copy highlighted text and confirm the clipboard contains plain text without markup.
- On a React-driven page, trigger a re-render (toggle a filter) and confirm the page does not throw; re-run the search and confirm highlights return.
- Run the feature’s teardown and confirm
CSS.highlights.size === 0in the page console’s isolated-world context.
FAQ
Can the page see my highlights?
It can see the registered highlight names in CSS.highlights from the main world in Chrome, since the registry belongs to the document. Do not encode private data in names.
Can I highlight across several elements?
A single Range can span nodes; build it with setStart in one text node and setEnd in another. The painting follows the range across element boundaries.
Why not use the browser’s find-in-page?
Extensions cannot drive the native find bar in Chrome. Firefox has a find API that can highlight results, which is a reasonable shortcut there.
Related
- Positioning tooltips near a text selection — using range rectangles for placement.
- Observing DOM changes efficiently with MutationObserver — re-running after re-renders.
- Removing injected CSS with removeCSS — undoing the paint rule.
- In-page overlays and injected UI — the parent topic.