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.ttscannot 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 skipnav,header,footerandasidewhen 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
activeTabmeans 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.remotebefore 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.