Skip to content

Read a whole document: element children and a provider #4

Description

@awlevin

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

  1. 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.
  2. What is the default skip list? See the section below. The mechanism is settled; the defaults are the open part.
  3. 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.
  4. Does <Transport> control the document or a segment? Suggest document level, with click-a-word to jump anywhere.
  5. 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

  • Children walk: collect text leaves, preserve element structure, wrap words
  • Block-level segmentation and a segment-aware controller
  • Lazy fetch with next-segment prefetch
  • <SpokenTextProvider> and context wiring for <SpokenText> and <Transport>
  • Skip mechanism: skip prop, data-spoken-skip, and sensible defaults
  • Tests: nested elements, links inside a sentence, skipped nodes, segment boundaries, a string child behaving exactly as today
  • Docs and a demo section built from a realistic multi-heading article

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.

  1. 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.
  2. 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.
  3. Children walk only. The component boundary is documented in one line. No DOM walking.
  4. 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.
  5. Headings are read. skip={["h1", "h2", "h3"]} is the opt-out.
  6. as defaults to "div" for element children, "p" for a string. A p cannot contain an h2.
  7. The player owns the document. One playhead, one duration; a word click seeks anywhere.
  8. <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.
  9. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions