Rehoboth Builds

The CSS Custom Highlight API does not cross a shadow boundary

If you are painting text you did not write — a find-in-page, an annotator, a read-aloud — shadow DOM breaks you twice, and neither break throws. First your walker never sees the text. Then, once you fix that, you highlight it and nothing appears on screen. Here is what we measured, and why the second failure is the one that costs you the afternoon.

7 September 2026

Failure one: a TreeWalker does not enter a shadow root

On 6 September 2026 we ran our collector over a page built for the purpose: one sentence in ordinary light DOM, one inside an open shadow root, one inside an iframe. It returned one sentence. No error, no warning — from the code's point of view the page simply had less text on it than it looked like it had.

The cause is not subtle once you see it. document.createTreeWalker walks a node tree, and a shadow root is a different node tree that happens to be attached to a host element in this one. The walker reaches the host, finds it has no children worth descending into, and moves on. Every design-system site — anything built on web components — hides a large share of its visible text behind exactly that boundary.

Two details decide whether the fix is correct rather than merely working:

  • Recurse at the host's position, not afterwards. When the walker accepts a host element, walk its shadowRoot right there, then let the walker continue. If you instead collect shadow trees in a second pass, the text arrives in the wrong order — and for a reader that means sentences spoken out of sequence, which reads as a much weirder bug than the one you were fixing.
  • Slotted content is not duplicated, if you let the walker do its normal job. Light DOM children assigned to a <slot> are reached once, by ordinary descent through the host. Walking the shadow tree sees only the empty <slot> element itself. So you do not need special handling — you need to not add any.

Closed shadow roots stay unreachable, and that is the intended behaviour, not an obstacle to route around. We also cap the recursion depth, because custom elements can host each other and a pathological nest should cost a bounded amount rather than the tab.

Failure two: the highlight registers, and stays invisible

This is the one worth reading the page for. The CSS Custom Highlight API is unusually pleasant for this job because it paints ranges without touching the DOM: no wrapper spans, no mutation of a page you do not own, nothing for the site's own scripts to trip over. You build a Highlight from a Range, register it under a name, and style that name with ::highlight(name).

The trap is that those two halves are scoped differently. In what we observed, registering the highlight once, on the document, was enough for a range whose text lives inside a shadow root — the registry did not need a per-tree copy. But the ::highlight() rule is resolved in the tree scope of the text it is meant to paint, and a <style> element in the document does not reach into a shadow root. So the highlight is registered, the range is valid, every check you would write returns green, and the user sees nothing.

The fix is to put the style where the text is: before highlighting, take range.startContainer.getRootNode(), and if it is a shadow root, append (or reuse) your <style> element in that root rather than in document.documentElement. One idempotent helper, called on every highlight, keyed by a fixed element id so repeated calls do not accumulate style tags.

Note the ordering hazard this creates for anyone porting the fix: walking into shadow DOM without also scoping the style is worse than not walking in at all. Before, the text was skipped and stayed unread. After, the reader speaks a sentence the user cannot see highlighted anywhere on the page — a product that looks broken in a way that points at the wrong subsystem.

Why both of these are hard to catch in a test

Both failures are green-passing. The collector returns a list, and a shorter list is still a list. The highlight registers, and the API reports no problem with a range it can resolve. Nothing in either path has an obvious assertion to write, which is why we ended up writing the two that matter: this page contains three sentences and the collector must return three, and a page whose text is inside a shadow root must end up with a style element inside that same root.

We also check the checks. For each assertion we make the fix wrong on purpose and confirm the count drops by exactly one: remove the shadow recursion and one specific assertion goes red; append the style to the document instead of the root and a different one does. An assertion that stays green when you break the thing it names is not evidence — and for silent failures it is the only kind of evidence available.

What is still broken here

The third sentence on our test page — the one inside an iframe — is still missing, and the technique above does not help: a shadow root is another tree in the same document, a frame is another document, and every scoping rule above has to be re-decided for it. We wrote that half the next day, and the shape of it is different enough to deserve its own page: the same API across a document boundary. A cross-origin frame stays unreachable from a top-level content script in either technique, and we would rather say so than have you assume the shadow fix covered it.

All of this came out of building a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is published on the Chrome Web Store as of 8 September 2026; what it does, what it cannot read, and what it will cost is written out plainly on its pre-order page, including the limitation in the paragraph above. To be exact about what you would be installing: the shadow-root support described on this page is written and tested but is not in the version currently on the store, whose description lists shadow DOM under what it cannot read. It goes out with the next release.