Elements

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>
  );
}