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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Choosing a highlighting techniqueDecision tree: text ranges use the CSS Custom Highlight API, falling back to mark elements; whole elements use overlay boxes in a shadow root; element changes that the page should keep are not highlights at all.What are you highlighting?text inside nodesCustom Highlight APIranges, no DOM changeFallback: <mark>only if API missingwhole elementsOverlay boxesin your shadow rootTrack getBoundingClientRecton scroll/resizepermanent changeNot a highlighthide or restyleinsertCSS ruleremoveCSS to undo
Paint over the page; do not rewrite it.

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.

How a custom highlight is paintedThe content script builds Range objects over existing text nodes and registers them as a named Highlight; a ::highlight rule in a page-level stylesheet paints them without inserting elements.TreeWalkertext nodes onlynew Range()start/end offsetsnew Highlight(...ranges)CSS.highlights.setpainted by a page-level ruleinsertCSS::highlight(readable-match)Rendererpaints rangesDOM unchangedno <mark> nodes
The page's DOM is never modified — frameworks, selection and copy keep working.

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.

Highlight techniques comparedCustom Highlight API, overlay boxes and mark-element wrapping compared on DOM changes, framework safety, copy-paste effects and browser support.TechniqueModifies DOMFramework-safeAffects copySupportCustom Highlight APINoYesNoChrome 105, Safar…Overlay boxesNo (own host only)YesNoAll<mark> wrappingYesOften breaksSometimesAll
The two non-destructive techniques cover almost every case.

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 with insertCSS.
  • 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: none turns your highlight into a wall over the page.

Cross-browser variation

  • Chrome / Edge: Custom Highlight API from 105; insertCSS for the paint rule.
  • Firefox: the Custom Highlight API arrived in recent versions; feature-detect CSS.highlights and use overlays or marks on older releases.
  • Safari: supports the highlight API from 17.2. On iOS, overlays positioned with position: fixed behave differently while the dynamic toolbar collapses; recompute on resize.

Verification

  1. Highlight a word on a long article and confirm matches are painted while DevTools’ Elements panel shows no new elements in the article.
  2. Select and copy highlighted text and confirm the clipboard contains plain text without markup.
  3. 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.
  4. Run the feature’s teardown and confirm CSS.highlights.size === 0 in 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.

Other UI/UX Patterns & Interactive Components Resources