Read aloud
An answer played back, the spoken word lit as it goes, speed under your thumb.
The converter dropped parts with no text, so the guard now keeps them and the suite passes again.
Installation
Install manually
npx shadcn@latest add "@assistant-ui/elements-read-aloud"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-read-aloud"Props-driven: no runtime or provider required.
Read aloud plays a message back while lighting up the word it's on, with a progress bar and a speed control. With a runtime the play state comes from assistant-ui's speech adapter; standalone the word index, the elapsed time, and the rate all come from you.
Getting started
Text-to-speech in assistant-ui is a speech adapter on the runtime plus a coarse status on the message being read. The runtime state has no word boundary or elapsed-time field, so the highlighted word, the elapsed and total time, and the rate come from your adapter or your app. This part of the runtime is experimental and may change.
Configure a speech adapter
"use client";
import { WebSpeechSynthesisAdapter } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
const runtime = useChatRuntime({
adapters: { speech: new WebSpeechSynthesisAdapter() },
});speech is one of the runtime's optional adapters, alongside things like attachments, and every runtime hook accepts it the same way.
Start and stop from the message
Render this inside the message, its action row, alongside copy and regenerate, so aui.message and s.message.speech resolve to that message:
import { useAui, useAuiState } from "@assistant-ui/react";
import { ReadAloud } from "@/components/assistant-ui/elements/read-aloud";
function Playback() {
const aui = useAui();
const speech = useAuiState((s) => s.message.speech);
const playing = speech?.status.type === "running" || speech?.status.type === "starting";
return (
<ReadAloud
words={[]}
spokenIndex={0}
playing={playing}
rate={1}
elapsed="0:00"
duration="0:00"
onToggle={() => (playing ? aui.message.stopSpeaking() : aui.message.speak())}
/>
);
}s.message.speech is undefined until speak() is called on that message and clears again once status.type reaches "ended"; it carries no word index, elapsed time, or rate. Those three props stay placeholders here until your app tracks them itself, commonly from the browser's own utterance boundary and end events if you're driving WebSpeechSynthesisAdapter directly instead of through speak().
Standalone, ReadAloud is a pure readout: give it the words, which index is being spoken, and the transport state, and it renders the highlight and the progress bar.
Drive it from an utterance
"use client";
import { useState } from "react";
import { ReadAloud } from "@/components/assistant-ui/elements/read-aloud";
const words = "Paris is the capital of France.".split(" ");
export function Playback() {
const [spokenIndex, setSpokenIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [rate, setRate] = useState(1);
return (
<ReadAloud
words={words}
spokenIndex={spokenIndex}
playing={playing}
rate={rate}
elapsed="0:02"
duration="0:05"
onToggle={() => setPlaying((p) => !p)}
onRateChange={() => setRate((r) => (r >= 2 ? 1 : r + 0.25))}
/>
);
}Advance the word index
If you're using the browser's native speech synthesis, its utterance fires a boundary event per word with a character offset; map that offset back to a word index to move the highlight forward as playback continues.
Anatomy
<div data-slot="read-aloud">
<p>{/* words, the spoken one highlighted */}</p>
<div>
<button aria-label="Play" />
<span role="progressbar">{/* progress bar, width = spokenIndex / words.length */}</span>
<span>{/* elapsed / duration */}</span>
<button>{/* rate, e.g. 1x */}</button>
</div>
</div>Progress is spokenIndex divided by words.length. There is no separate progress prop, so the bar and the highlighted word always agree. The bar is a named progressbar with a 0โฆ100 value matching that width, and its value text reads the same elapsed and duration the row prints. Words before spokenIndex dim, the current word gets a highlight background, and the words after it keep the regular text color, so spokenIndex alone determines all three states.
Examples
Rate control
<ReadAloud
words={words}
spokenIndex={2}
playing
rate={1.5}
elapsed="0:03"
duration="0:08"
onRateChange={() => cycleRate()}
/>rate is a display value; ReadAloud doesn't clamp or format it, and onRateChange takes no argument, so cycling through a fixed list of rates, like 1, 1.25, 1.5, 2, is on you.
Before playback starts
At spokenIndex={0} with playing={false}, no word is dimmed, the first word still gets the highlight background, and the progress bar is at 0%. Hold elapsed at "0:00" to match.
Before speak() has been called, s.message.speech is undefined; derive playing={false} from that, and there's no spoken word yet since nothing has streamed a boundary event.
API reference
Message methods and state
| Member | Type | Description |
|---|---|---|
aui.message.speak() | () => void | Starts reading the current message aloud. Experimental. |
aui.message.stopSpeaking() | () => void | Stops it. Experimental. |
s.message.speech | { messageId: string; status } | undefined | status.type is "starting", "running", or "ended". No word index, elapsed time, or rate. Experimental. |
ReadAloud
| Prop | Type | Default | Description |
|---|---|---|---|
words | readonly string[] | required | Split however you want them highlighted; rendered space-joined. |
spokenIndex | number | required | Index of the currently highlighted word; also drives the progress bar. |
playing | boolean | required | Swaps the play and pause icon. |
rate | number | required | Shown as ${rate}x; purely a display value. |
elapsed | string | required | Already formatted, for example "0:42". |
duration | string | required | Already formatted. |
onToggle | () => void | Called by the play and pause button. | |
onRateChange | () => void | Called by the rate button; takes no argument. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.