Highlighting text inside an iframe: the API is per-realm, and that is four separate problems
Getting into a same-origin frame from a content script is one line.
Everything after that line is where the afternoon goes: a Range, a
<style>, a highlight registry and a set of coordinates that all silently
belong to a document you are no longer in. Miss any one and the text is read but nothing on
the screen moves.
8 September 2026 · companion to the same API across a shadow boundary
Getting in is one line, in one specific place
A TreeWalker does not descend into a frame, for the same reason it does not
descend into a shadow root: the frame's content is a different node tree. For a
same-origin frame you can simply take el.contentDocument and
recurse into its body at the position the walker reached the frame element —
at that position, not in a second pass, or your sentences arrive out of order.
The placement detail that cost us a build: most collectors already have a skip list of
non-text media, and IFRAME is almost always on it. The recursion has to be
decided before that skip test runs, otherwise the frame is rejected as media and
you never reach the branch you just wrote. Read contentDocument inside a
try as well — for a cross-origin, data: or sandboxed frame it
throws or returns null, and that is the answer, not an error to route around.
Then four things, each scoped to that document
The CSS Custom Highlight API paints ranges without touching the DOM, which is exactly what you want on a page you do not own. But every object involved is per-document or per-realm, and none of the mismatches throw a useful error:
- The Range. A
Rangecannot span documents, and one created by the top-leveldocument.createRange()will not accept a node that lives in a frame. Build it fromnode.ownerDocument, at both of the places you construct ranges — ours are in the highlight path and in the relocation path that repairs offsets after the page mutates, and only fixing the first gets you an intermittent bug. - The style. A
::highlight()rule is resolved in the tree scope of the text it paints. A<style>in the top document reaches nothing inside the frame. Append it to that document instead, keyed by a fixed element id so repeated calls do not accumulate tags. - The registry.
CSS.highlightsand theHighlightconstructor are per-realm: use the frame window's own. This one carries a bug you will not predict — registering under the same name in a different registry does not replace the previous entry, it adds a second one. Reading sentence n in the frame and sentence n+1 in the top document leaves two sentences lit at once. The fix is to keep the list of windows you registered in and clear all of them before each new sentence. - The coordinates. Client rects inside a frame are relative to that frame's viewport. To draw a fallback overlay on the top document, or to scroll, add back each frame element's position in its parent — including its border and padding, because the content box does not start at the outer edge. And scrolling is two independent steps: scroll the frame so the sentence is inside it, then scroll the top document so the frame is on screen. Do only one and the voice moves down the page while the picture stays put.
The failure shape is worse than not supporting frames
Notice what each of these four failures looks like from the outside: the text is collected, the sentence is spoken, no console error appears — and the highlight is nowhere on the page. That is strictly harder to diagnose than the original behaviour, where frame text was simply skipped and stayed silent. If you are porting this, do the walk and the four scoping fixes in one change, or you ship a regression that points at the wrong subsystem.
We caught the double-highlight one only because a test asserted that the previous sentence stops being lit — not because anyone predicted it. For each of these we make the fix wrong on purpose and confirm exactly the expected assertions go red: force the frame document to null and three go red; hard-code the style into the top document and one does; delete the clear-all step 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 available.
What we did not solve
Cross-origin frames. A top-level content script cannot see into them at all, by design; covering them needs injection into every frame plus a protocol between them to keep one ordered sequence across documents. We have not built that, and we are not going to pretend the same-origin fix covers the embedded-PDF-viewer or ad-frame case.
All of this came out of building Highlight Reader, a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is free on the Chrome Web Store and uses the voices Chrome already ships. To be exact about what you would be installing: the frame support described on this page is written and tested but is not in the version currently on the store — and neither is the shadow-root support of the companion page. What is on the store today reads ordinary page text; its own store description says so. A paid add-on — word-level highlighting and higher-quality voices — is described here, and it is an add-on, not the extension.