Elements

Elements · Voice

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.

0:00 / 0:05
fig. 01

Installation

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 init

Then 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.

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 this rides assistant-ui's speech adapter for the coarse play state; standalone every number, the word index, the elapsed time, the rate, comes from you.

Getting started

Text-to-speech in assistant-ui is a speech adapter on the runtime and a coarse status on the message it's reading; there's no word-level boundary or elapsed-time field in the runtime state itself, so the fine detail this element shows, which word is lit, elapsed against duration, a rate you can change, is something your adapter or your app supplies on top. This part of the runtime is still experimental and may change.

Configure a speech adapter

app/layout.tsx
"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 "./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().

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 computed purely from spokenIndex divided by words.length, there's 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 rest stay at full opacity, so spokenIndex alone determines all three states across the whole sentence.

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

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

MemberTypeDescription
aui.message.speak()() => voidStarts reading the current message aloud. Experimental.
aui.message.stopSpeaking()() => voidStops it. Experimental.
s.message.speech{ messageId: string; status } | undefinedstatus.type is "starting", "running", or "ended". No word index, elapsed time, or rate. Experimental.