Problem
Today, wiring a player to a passage means the consumer holds the controller themselves:
const speech = useSpokenText(text);
<SpokenText speech={speech} />
<Transport speech={speech} />
Two problems with that.
1. The hook is plumbing, not a feature. Its only job is to let two siblings share one controller. That is a job React context exists to do.
2. children must be a string. So a real page has to be decomposed into one <SpokenText> per paragraph, each separately wired, and nothing reads continuously across them.
The motivating case is a study guide: a heading, some paragraphs, a subheading, more paragraphs, links throughout. Someone should be able to press play once and have the whole thing read, with the highlight moving through it. See e.g. a Fiveable study guide.
Proposal
Two additions. Nothing is removed and nothing existing changes behaviour.
1. <SpokenText> accepts elements, not just a string
<SpokenText>
<h2>Causes of the War</h2>
<p>By 1754, European countries were competing for world domination…</p>
<h3>Native American Interests</h3>
<p>Generally, Britain would fight against either <a href="/france">France</a>, Spain, or both…</p>
</SpokenText>
<SpokenText> walks the children tree, collects text leaves in order, and re-renders the same structure with the text nodes replaced by word spans. h2 stays an h2, links stay links, only text gets wrapped.
Internally it segments at block boundaries. Each heading and paragraph becomes its own alignment request, so:
- each block caches independently, and editing one paragraph does not invalidate the page
- playback can start at any block
- audio is fetched lazily, prefetching the next block while the current one plays, instead of one enormous request on mount
children: string keeps working exactly as it does now. It is the same code path with one segment.
2. <SpokenTextProvider> so the player can live anywhere
<SpokenTextProvider>
<StickyHeader>
<Transport />
</StickyHeader>
<article>
<SpokenText>{/* the whole document */}</SpokenText>
</article>
</SpokenTextProvider>
<Transport> with no speech prop reads the controller from context. <SpokenText> with no speech prop registers itself with the provider. Neither needs the other to be a sibling, so the player can be sticky, in a header, or in a portal.
useSpokenText stays exported for custom players and stays the escape hatch. <SpokenText> on its own, with no provider, still self-manages.
The limitation to decide on
React children are opaque until rendered. A tree walk can see literal elements and strings; it cannot see inside a child component.
<SpokenText><p>Some text</p></SpokenText> ✅ works
<SpokenText><MyArticle /></SpokenText> ❌ text is invisible
MDX output is fine, because MDX hands you real <h2> / <p> elements. Hand-composed pages are fine. It breaks only across a component boundary.
The alternative is walking the DOM after render with a TreeWalker and wrapping text nodes in place. That handles any nesting, including dangerouslySetInnerHTML, but it means mutating DOM that React owns, it is client-only, and it fights hydration. Recommendation: ship the children walk, document the boundary, and treat DOM walking as a separate opt-in escape hatch only if someone actually needs it.
Open questions
- Are headings read aloud, or skipped and highlighted in place? Reading them is more faithful; skipping them is often what a listener wants. Suggest reading by default with an opt-out.
- What is the default skip list? See the section below. The mechanism is settled; the defaults are the open part.
- What is the unit of
currentWordIndex? It is currently an index into one passage. With segments it needs to be either a global index or a { segment, word } pair. This is a breaking change to the controller shape if done wrong, so decide before it ships.
- Does
<Transport> control the document or a segment? Suggest document level, with click-a-word to jump anywhere.
- Should segmentation be overridable? A
segment prop for people who want sentence-level or whole-document granularity.
Skipping content
Wrapping a whole article means wrapping things nobody wants read aloud. A code block is the obvious one: useSpokenText(text, { debounceMs: 900 }) read as prose is noise, and it would wreck the alignment besides.
Three ways to exclude, in order of how often they will be reached for.
1. Sensible defaults. Some elements are almost never worth speaking. Proposed default skip list:
code, pre, kbd, samp, var, script, style, svg, canvas, iframe, math
Skipped by default; a consumer can opt back in by passing their own list.
2. A skip prop, taking tag names or a predicate:
<SpokenText skip={["code", "pre", "figcaption", ".footnote"]}>
{children}
</SpokenText>
<SpokenText skip={(el) => el.type === "aside" || el.props?.["aria-hidden"]}>
{children}
</SpokenText>
The array form covers the common case. The predicate receives the React element, so it can look at type, props.className, or anything else on it.
3. data-spoken-skip on the element itself, for one-off exclusions at the point of authorship, where the person writing the content is not the person configuring the component. Useful in MDX.
Two questions this raises
Does skipping remove the text, or just leave it unspoken? Skipped content must still render, untouched, exactly where it is. It is dropped only from the text handed to alignment. So an inline <code> in the middle of a sentence leaves a gap in what is spoken, and the words on either side stay correctly timed.
What about inline versus block? Skipping a block is easy: it becomes its own boundary. Skipping something inline is the subtle case. If a sentence reads "pass the debounceMs option", the spoken audio has to be generated from the text with the code span removed, or the alignment will drift trying to match a word that was never said. Worth an explicit test.
Scope
Compatibility
Additive. children: string, speech, useSpokenText, classNames, renderWord all keep working. Question 3 is the one place a careless change would break the public shape, so it needs settling first.
Targeting 0.2.0.
Decisions (2026-09-02)
The open questions above are settled. Two things are added to scope.
currentWordIndex is global. One number across the whole document, so a string child is a one-segment document and nothing existing changes. segments: { start, end, status }[] is exposed alongside. The index is an address, not a fetch: every word is tokenized locally on mount, each block aligns independently, and a word in an unloaded block is future, untimed and seekable: false until its block lands. Clicking one fetches that block and plays from there.
duration is estimated, then corrected. Until every block has loaded, duration is the loaded blocks' real audio plus an estimate for the rest (from word count), and the controller exposes durationIsEstimate: boolean. <Player> shows the time dimmed with a tilde (~1:42) while it is an estimate. The scrubber corrects as blocks land.
- Children walk only. The component boundary is documented in one line. No DOM walking.
- Inline skips are excised from the spoken text and left in the DOM. The sentence is synthesized without the skipped span; the element renders in place, unhighlighted. A test asserts the word after the skip lights on time.
- Headings are read.
skip={["h1", "h2", "h3"]} is the opt-out.
as defaults to "div" for element children, "p" for a string. A p cannot contain an h2.
- The player owns the document. One playhead, one duration; a word click seeks anywhere.
<Transport> is renamed <Player>, outright. "Transport" is DAW jargon; it is a play button and a scrubber. No alias, no deprecation shim: nobody depends on the package yet, so nothing is kept for compatibility. The same rule holds across the change — no dead branches or re-exports preserved for the old shape.
only joins skip, same grammar. Both accept an array of selectors ("pre", ".spoken", "[data-x]") or a predicate over the React element. only fences the field, skip cuts inside it. only is unset by default; the default skip is code, pre, kbd, samp, var, script, style, svg, canvas, iframe, math. data-spoken and data-spoken-skip are the per-element forms.
Also needed: a multi-heading article example on the demo page, the README tables, and a fresh docs/demo.gif.
Problem
Today, wiring a player to a passage means the consumer holds the controller themselves:
Two problems with that.
1. The hook is plumbing, not a feature. Its only job is to let two siblings share one controller. That is a job React context exists to do.
2.
childrenmust be astring. So a real page has to be decomposed into one<SpokenText>per paragraph, each separately wired, and nothing reads continuously across them.The motivating case is a study guide: a heading, some paragraphs, a subheading, more paragraphs, links throughout. Someone should be able to press play once and have the whole thing read, with the highlight moving through it. See e.g. a Fiveable study guide.
Proposal
Two additions. Nothing is removed and nothing existing changes behaviour.
1.
<SpokenText>accepts elements, not just a string<SpokenText>walks the children tree, collects text leaves in order, and re-renders the same structure with the text nodes replaced by word spans.h2stays anh2, links stay links, only text gets wrapped.Internally it segments at block boundaries. Each heading and paragraph becomes its own alignment request, so:
children: stringkeeps working exactly as it does now. It is the same code path with one segment.2.
<SpokenTextProvider>so the player can live anywhere<Transport>with nospeechprop reads the controller from context.<SpokenText>with nospeechprop registers itself with the provider. Neither needs the other to be a sibling, so the player can be sticky, in a header, or in a portal.useSpokenTextstays exported for custom players and stays the escape hatch.<SpokenText>on its own, with no provider, still self-manages.The limitation to decide on
React children are opaque until rendered. A tree walk can see literal elements and strings; it cannot see inside a child component.
MDX output is fine, because MDX hands you real
<h2>/<p>elements. Hand-composed pages are fine. It breaks only across a component boundary.The alternative is walking the DOM after render with a
TreeWalkerand wrapping text nodes in place. That handles any nesting, includingdangerouslySetInnerHTML, but it means mutating DOM that React owns, it is client-only, and it fights hydration. Recommendation: ship the children walk, document the boundary, and treat DOM walking as a separate opt-in escape hatch only if someone actually needs it.Open questions
currentWordIndex? It is currently an index into one passage. With segments it needs to be either a global index or a{ segment, word }pair. This is a breaking change to the controller shape if done wrong, so decide before it ships.<Transport>control the document or a segment? Suggest document level, with click-a-word to jump anywhere.segmentprop for people who want sentence-level or whole-document granularity.Skipping content
Wrapping a whole article means wrapping things nobody wants read aloud. A code block is the obvious one:
useSpokenText(text, { debounceMs: 900 })read as prose is noise, and it would wreck the alignment besides.Three ways to exclude, in order of how often they will be reached for.
1. Sensible defaults. Some elements are almost never worth speaking. Proposed default skip list:
Skipped by default; a consumer can opt back in by passing their own list.
2. A
skipprop, taking tag names or a predicate:The array form covers the common case. The predicate receives the React element, so it can look at
type,props.className, or anything else on it.3.
data-spoken-skipon the element itself, for one-off exclusions at the point of authorship, where the person writing the content is not the person configuring the component. Useful in MDX.Two questions this raises
Does skipping remove the text, or just leave it unspoken? Skipped content must still render, untouched, exactly where it is. It is dropped only from the text handed to alignment. So an inline
<code>in the middle of a sentence leaves a gap in what is spoken, and the words on either side stay correctly timed.What about inline versus block? Skipping a block is easy: it becomes its own boundary. Skipping something inline is the subtle case. If a sentence reads "pass the
debounceMsoption", the spoken audio has to be generated from the text with the code span removed, or the alignment will drift trying to match a word that was never said. Worth an explicit test.Scope
<SpokenTextProvider>and context wiring for<SpokenText>and<Transport>skipprop,data-spoken-skip, and sensible defaultsCompatibility
Additive.
children: string,speech,useSpokenText,classNames,renderWordall keep working. Question 3 is the one place a careless change would break the public shape, so it needs settling first.Targeting
0.2.0.Decisions (2026-09-02)
The open questions above are settled. Two things are added to scope.
currentWordIndexis global. One number across the whole document, so a string child is a one-segment document and nothing existing changes.segments: { start, end, status }[]is exposed alongside. The index is an address, not a fetch: every word is tokenized locally on mount, each block aligns independently, and a word in an unloaded block isfuture, untimed andseekable: falseuntil its block lands. Clicking one fetches that block and plays from there.durationis estimated, then corrected. Until every block has loaded,durationis the loaded blocks' real audio plus an estimate for the rest (from word count), and the controller exposesdurationIsEstimate: boolean.<Player>shows the time dimmed with a tilde (~1:42) while it is an estimate. The scrubber corrects as blocks land.skip={["h1", "h2", "h3"]}is the opt-out.asdefaults to"div"for element children,"p"for a string. Apcannot contain anh2.<Transport>is renamed<Player>, outright. "Transport" is DAW jargon; it is a play button and a scrubber. No alias, no deprecation shim: nobody depends on the package yet, so nothing is kept for compatibility. The same rule holds across the change — no dead branches or re-exports preserved for the old shape.onlyjoinsskip, same grammar. Both accept an array of selectors ("pre",".spoken","[data-x]") or a predicate over the React element.onlyfences the field,skipcuts inside it.onlyis unset by default; the defaultskipiscode, pre, kbd, samp, var, script, style, svg, canvas, iframe, math.data-spokenanddata-spoken-skipare the per-element forms.Also needed: a multi-heading article example on the demo page, the README tables, and a fresh
docs/demo.gif.