Rehoboth Builds

Reading a page aloud in Chrome and highlighting each sentence

Two problems look easy and are not: knowing where the speech engine currently is, and drawing a highlight there without breaking the page. This is how we solved both while building a reader extension, including the failure mode where the audio is perfect and the highlight never moves.

6 September 2026

Do not trust boundary events

The obvious design is: hand the whole article to chrome.tts.speak() and follow the sentence or word events to move the highlight. The API documents both event types, so this reads like the supported path.

It is not portable, and you can check that before you write a line of speech code: every voice chrome.tts.getVoices() returns carries an eventTypes array saying which events it will actually emit. On our Linux test machine all nineteen voices Chrome offered listed exactly start, end, interrupted, cancelled, error — not one advertised sentence or word. If you build on boundary events, your highlight sits on sentence one while the audio runs to the end, and it looks fine on your machine because your machine happens to have a chatty voice.

The portable version is boring: split the text yourself and queue one utterance per sentence. Every engine emits start and end per utterance, because that is Chrome's own bookkeeping rather than the voice's. The start event of utterance i is your cue to highlight sentence i.

The cost is honest and worth stating: you get the engine's inter-utterance pause between every sentence, which is slightly longer than its natural sentence pause. We took that trade — a highlight that is correct everywhere beats prosody that is smooth on one machine.

Then do not depend on start either. On a machine whose only available voices were network ones, our first start event arrived 23 seconds after the speak() call while the queue was already running. So we drive the highlight from both ends: start of utterance i highlights sentence i, and end of utterance i immediately highlights i+1. The two agree, the update is idempotent, and either event alone is enough to keep the page in sync. We also highlight the first sentence at the moment the user presses play, before any event exists.

Highlight without touching the DOM

The traditional trick is to wrap the current sentence in a <span>. On a modern page this is a bug generator: you are mutating text nodes inside someone else's React or Vue tree, and the next re-render either erases your span or fights you for it.

Use the CSS Custom Highlight API instead. You build a Range over the sentence, register it, and style it from a stylesheet — the DOM is never modified:

const hl = new Highlight(range);
CSS.highlights.set('reader-current', hl);
// stylesheet:  ::highlight(reader-current) { background: #ffd54a; color: #222; }

Two things the reference does not tell you. First, ::highlight() only accepts a small set of colour-ish properties — no padding, no border, no box-shadow — so plan a design that is a background and a text colour and nothing else. Second, set the text colour explicitly. If you only set a background, a page in dark mode gives you white text on your yellow highlight, which is the least readable thing on the screen at exactly the moment the user is looking at it.

The API is in Chrome from 105. If you want a fallback for older or flag-disabled setups, do not fall back to spans — call range.getClientRects() and draw a few translucent absolutely-positioned rectangles. Still no DOM mutation.

The MV3 service worker will be collected mid-sentence

A Manifest V3 background worker is terminated after roughly 30 seconds without events. Speech is not activity: the audio is played by the browser process, so nothing you do in the worker keeps it alive, and if it is collected mid-article the user hears a fluent reading while the highlight stays wherever it was — every later event lands on nothing. A ten-second smoke test cannot show you this; the arithmetic of the idle timer against the length of a real article is what should worry you.

So keep the worker alive for the duration of playback by touching a chrome API on a timer — we ping every 20 seconds while, and only while, speech is running:

// while speaking:
timer = setInterval(() => chrome.runtime.getPlatformInfo(() => {}), 20000);
// on stop / end / error:  clearInterval(timer)

Do not leave the timer running when nothing is being spoken; that is the pattern review teams reasonably ask about, and it drains battery for no benefit.

Two smaller things that cost us time

  • Changing speed or voice mid-article restarts the sentence. An utterance already handed to chrome.tts cannot be re-rated. The only honest behaviour is to cancel and re-speak the current sentence at the new setting — so make sentences your unit everywhere, not just for highlighting.
  • Skip the chrome, not just the scripts. Excluding <script> and <style> is not enough: when the user starts from the top of a page, nobody wants the navigation menu read aloud first. We skip nav, header, footer and aside when reading a whole document — but not when the user has selected text inside one, because then they asked for it.
  • You do not need host permissions. Injecting on the user's click via activeTab means the extension installs with no site access at all, which removes the scariest line from the install dialog. Chrome grants it on four gestures, and opening your popup is one of them.
  • Check TtsVoice.remote before you promise privacy. A remote voice is synthesised by a network service, so selecting one means Chrome sends the text being spoken to that provider. On the machine above, every available voice was remote. If your extension claims nothing leaves the device, that claim is about your code, not about the voice the user picked — group the list and say which is which.

Known limits, stated up front

If the page re-renders while it is being read — a single-page app swapping content — the stored ranges go stale and those sentences simply do not highlight; the audio continues. And voice availability is entirely the platform's: a machine can have no on-device voice at all, in which case speak() still succeeds and the audio depends on a network service you do not control. Neither of these is fixable from inside the extension, so we say so rather than pretending.

We built this into a small Chrome extension that reads the page aloud and highlights each sentence as it goes. What it does, and what it will cost — it is a pre-order page, so it says plainly what exists today and what does not.