87 / 122 · 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
Installation
npx shadcn@latest add "@assistant-ui/elements-read-aloud"
Usage
import { ReadAloud } from "@/components/elements/read-aloud";
<ReadAloud
words={words}
spokenIndex={12}
playing
rate={1}
elapsed="0:04"
duration="0:11"
onToggle={toggle}
onRateChange={cycleRate}
/>Props
words*string[]The answer split into words, so the spoken one can be lit without re-parsing the text.
spokenIndex*numberIndex of the word being spoken. Everything before it dims, everything after stays waiting.
playing*booleanSwaps the transport button between play and pause.
rate*numberPlayback speed, printed on the rate control.
elapsed*stringPre-formatted elapsed time.
duration*stringPre-formatted total duration.
onToggle() => voidCalled when playback is started or paused.
onRateChange() => voidCalled when the rate control is tapped; cycle the rate yourself.
classNamestringExtra classes merged onto the root.
Source
read-aloud.tsx"use client";
import { PauseIcon, PlayIcon, Volume2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton, mono, paper } from "./surfaces";
export function ReadAloud({
words,
spokenIndex,
playing,
rate,
elapsed,
duration,
onToggle,
onRateChange,
className,
}: {
words: readonly string[];
spokenIndex: number;
playing: boolean;
rate: number;
elapsed: string;
duration: string;
onToggle?: () => void;
onRateChange?: () => void;
className?: string;
}) {
const progress =
words.length === 0 ? 0 : Math.min(100, (spokenIndex / words.length) * 100);
return (
<div
className={cn(
paper,
"flex w-full max-w-sm flex-col gap-3 rounded-2xl p-3.5",
className,
)}
>
<p className="text-[13.5px] leading-relaxed">
{words.map((word, i) => (
<span
key={`${i}-${word}`}
className={cn(
"transition-colors duration-200 motion-reduce:transition-none",
i < spokenIndex
? "text-foreground/40"
: i === spokenIndex
? "text-foreground/95 rounded bg-blue-500/12 dark:bg-blue-400/15"
: "text-foreground/70",
)}
>
{word}{" "}
</span>
))}
</p>
<div className="flex items-center gap-2.5">
<button
type="button"
aria-label={playing ? "Pause" : "Play"}
onClick={onToggle}
className={cn(ghostButton, "bg-foreground/[0.06] size-8 shrink-0")}
>
{playing ? (
<PauseIcon className="size-3.5" />
) : (
<PlayIcon className="size-3.5 translate-x-px" />
)}
</button>
<span className="bg-foreground/[0.08] h-[3px] min-w-0 flex-1 overflow-hidden rounded-full">
<span
className="block h-full rounded-full bg-blue-500 transition-[width] duration-200 ease-linear motion-reduce:transition-none dark:bg-blue-400"
style={{ width: `${progress}%` }}
/>
</span>
<span className={cn(mono, "text-foreground/35 shrink-0 tabular-nums")}>
{elapsed} / {duration}
</span>
<button
type="button"
aria-label={`Playback speed, currently ${rate} times`}
onClick={onRateChange}
className={cn(
field,
mono,
"text-foreground/55 hover:text-foreground/90 shrink-0 rounded-full px-2 py-1 tabular-nums transition-colors",
)}
>
{rate}×
</button>
<Volume2Icon className="text-foreground/25 size-3.5 shrink-0" />
</div>
</div>
);
}