Wrap your text. Hear it read. Watch every word light up.
One React component reads your text aloud and lights each word as it is said.
Click any prose on this page to hear it
import { SpokenText, Transport, useSpokenText } from "spoken-text";
export default function Reader() {
const speech = useSpokenText("The tide came in slowly that morning, and the boats leaned over in the mud until the water found them again. By noon the harbour was full, and nobody could remember what the bottom looked like.", {
debounceMs: 900, // wait for typing to settle
});
return (
<>
<SpokenText speech={speech} />
<Transport speech={speech} />
</>
);
}The tide came in slowly that morning, and the boats leaned over in the mud until the water found them again. By noon the harbour was full, and nobody could remember what the bottom looked like.
Composing
Press play, or click any word to hear the passage from there. Show the code and edit the string to make it say something else.
React 18 or 19 is the only requirement. The speech, the transcript and the storage are all functions you pass in.
import { SpokenText } from "spoken-text";
<SpokenText>Any text you like.</SpokenText>The route
server<SpokenText> posts the passage to /api/transcription and expects audio and word timings back. Mounting that route is the other half, and it is one export.
import {
createAlignmentHandler,
openaiSpeech,
openaiTranscription,
vercelBlobCache,
} from "spoken-text/server";
export const POST = createAlignmentHandler({
speech: openaiSpeech({ model: "tts-1", voice: "nova" }),
transcribe: openaiTranscription({
model: "whisper-1",
language: "en",
}),
cache: vercelBlobCache(),
});- It is a plain handler
A (Request) => Response that reads { content } and returns audio plus word-level timings. It mounts anywhere that speaks the web standard, not only Next.js.
- The adapters are opt-in
openaiSpeech, openaiTranscription and vercelBlobCache ship with the package, but nothing depends on them. Pass your own speech, transcribe and cache and the package asks for nothing but React.
- Generated once, ever
The cache key is a hash of the passage, so identical text hits the same entry. Without a cache the audio comes back inline as a data: URL, which is fine for a first run and wrong for anything after it.
- Environment
Those two adapters read OPENAI_API_KEY and BLOB_READ_WRITE_TOKEN.
The API
exportsThe whole surface, so you can see where the escape hatches are.
The passage to speak. Required unless you pass speech.
A controller from useSpokenText, so a passage and a <Transport> share one audio element.
Your classes per word state. Supplying one drops the built-in look for that slot, so your CSS is not fighting inline styles.
Take over word rendering entirely. Whitespace is still inserted for you.
Fires whenever the spoken word changes. Index -1 means nothing has been said yet.
Where the passage is posted.
Skip the route and resolve audio and timings yourself.
Element the passage renders into. Also takes className and style.
Click a word to hear the passage from there.
Speak as soon as the audio is ready.
How long to wait after the text stops changing before fetching.
text · words · currentWordIndex · currentWord
status · isLoading · isPlaying · error · currentTime · duration · audioUrl
play() · pause() · toggle() · seek(seconds) · seekToWord(index) · seekToFraction(0-1)
getAudioElement()
The controller to drive. A play button and a scrubber; optional, since the passage highlights on its own.
Restyle any part of it. Also takes showTime and showStatus.
<SpokenText
endpoint="/api/speech"
classNames={{ current: "bg-sky-200" }}
onWordChange={(index, word) =>
setCaption(word?.text ?? "")
}
>
{text}
</SpokenText>tokenize · alignTokens · tokenIndexAt · normalizeForAlignment · createEndpointAligner · clearAlignmentCache · DEFAULT_ENDPOINT
createAlignmentHandler · openaiSpeech · openaiTranscription · vercelBlobCache · sha256Hex