Transcript
AIProA recording as speaker turns, with the word being played marked and the words the recognizer was unsure of underlined for a person to check.
npx prodular-ui add transcriptPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Follows the recording
Play the call or drag the waveform. The current turn and word follow, and a turn's time plays from its start.
Call with Tidewater Freight
0:19 / 0:43- Maya OkaforHi Daniel, it's Maya from Halcyon Labs. I'm calling about invoice INV-2047, (unclear) which was due on October 3.
- Daniel ReyesRight, we held it. The onboarding line came in at six thousand, and our purchase order says forty five hundred.
- Maya OkaforThat's fair. Onboarding ran two weeks over, but the order caps it. I can reissue it at forty five hundred today.
- Daniel ReyesIf it's reissued today, we can pay it in the Thursday (unclear) run. Send it to accounts payable.
- Maya OkaforWill do. You'll have the corrected invoice within the hour.
"use client"
import * as React from "react"import { PauseIcon, PlayIcon } from "lucide-react"
import { Button } from "@/components/ui/button"import { Transcript, TranscriptTurn, TranscriptWords, type TranscriptWordData } from "@/components/ui/transcript"import { Waveform } from "@/components/ui/waveform"
const speakers = { maya: { name: "Maya Okafor", color: "var(--chart-1)" }, daniel: { name: "Daniel Reyes", color: "var(--chart-2)" },}type Speaker = keyof typeof speakers
// What the recognizer returned for each turn. Words it was unsure of carry a low confidence.const unsure: Record<string, number> = { "INV-2047,": 0.52, Thursday: 0.61 }const script: [Speaker, number, number, string][] = [ ["maya", 0, 8.6, "Hi Daniel, it's Maya from Halcyon Labs. I'm calling about invoice INV-2047, which was due on October 3."], ["daniel", 9.2, 17.8, "Right, we held it. The onboarding line came in at six thousand, and our purchase order says forty five hundred."], ["maya", 18.4, 28.2, "That's fair. Onboarding ran two weeks over, but the order caps it. I can reissue it at forty five hundred today."], ["daniel", 28.8, 36.4, "If it's reissued today, we can pay it in the Thursday run. Send it to accounts payable."], ["maya", 37, 42.6, "Will do. You'll have the corrected invoice within the hour."],]const DURATION = 43const turns = script.map(([who, start, end, text]) => { const parts = text.split(" ") const step = (end - start) / parts.length const words: TranscriptWordData[] = parts.map((w, i) => ({ text: w, start: start + i * step, end: start + (i + 1) * step, confidence: unsure[w] ?? 0.96, })) return { who, start, end, words }})const BARS = 150const bars = Array.from({ length: BARS }, (_, i) => { const t = ((i + 0.5) / BARS) * DURATION const turn = turns.find((x) => t >= x.start && t < x.end) return { value: turn ? 0.3 + 0.6 * Math.abs(Math.sin(i * 1.3) * Math.cos(i * 0.29)) : 0.05, color: turn ? speakers[turn.who].color : null, }})
function format(seconds: number) { const s = Math.floor(seconds) return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
export default function TranscriptCall() { const [time, setTime] = React.useState(19.6) const [playing, setPlaying] = React.useState(false)
React.useEffect(() => { if (!playing) return let last = performance.now() let frame = requestAnimationFrame(function tick(now) { setTime((t) => Math.min(t + (now - last) / 1000, DURATION)) last = now frame = requestAnimationFrame(tick) }) return () => cancelAnimationFrame(frame) }, [playing])
if (playing && time >= DURATION) setPlaying(false)
return ( <div className="flex w-full max-w-xl flex-col rounded-xl border bg-card shadow-xs"> <div className="flex flex-col gap-3 border-b p-4"> <div className="flex items-baseline justify-between gap-3"> <p className="m-0 text-sm font-medium">Call with Tidewater Freight</p> <span className="text-[13px] text-muted-foreground tabular-nums"> {format(time)} / {format(DURATION)} </span> </div> <div className="flex items-center gap-3"> <Button size="icon" variant="outline" className="rounded-full" aria-label={playing ? "Pause" : "Play"} onClick={() => { if (time >= DURATION) setTime(0) setPlaying((p) => !p) }} > {playing ? <PauseIcon className="fill-current" /> : <PlayIcon className="translate-x-px fill-current" />} </Button> <Waveform values={bars.map((b) => b.value)} colors={bars.map((b) => b.color)} progress={time / DURATION} duration={DURATION} onSeek={(p) => setTime(p * DURATION)} label="Call with Tidewater Freight" className="h-10" /> </div> </div> <Transcript className="max-h-72 overflow-y-auto p-4"> {turns.map((turn) => ( <TranscriptTurn key={turn.start} speaker={speakers[turn.who].name} color={speakers[turn.who].color} start={turn.start} active={time >= turn.start && time < turn.end + 0.6} onSeek={setTime} > <TranscriptWords words={turn.words} currentTime={time} /> </TranscriptTurn> ))} </Transcript> </div> )}Unsure words
Words under the confidence threshold are underlined, and screen readers hear them marked as unclear. A turn can also take plain text.
- Priya Natarajan10:12We moved the Halden Analytics renewal to net 45, signed by Oskar (unclear) Lind (unclear) on Friday.
- Tom Brandt10:19Then the October forecast needs the $86,400 moved into November. I will update it before the close.
import { Transcript, TranscriptTurn, TranscriptWords, type TranscriptWordData } from "@/components/ui/transcript"
function words(text: string, start: number, unsure: string[] = []): TranscriptWordData[] { return text.split(" ").map((w, i) => ({ text: w, start: start + i * 0.4, end: start + (i + 1) * 0.4, confidence: unsure.includes(w) ? 0.48 : 0.95, }))}
export default function TranscriptReview() { return ( <Transcript className="w-full max-w-lg"> <TranscriptTurn speaker="Priya Natarajan" color="var(--chart-3)" start={612}> <TranscriptWords words={words("We moved the Halden Analytics renewal to net 45, signed by Oskar Lind on Friday.", 612, ["Oskar", "Lind"])} /> </TranscriptTurn> <TranscriptTurn speaker="Tom Brandt" color="var(--chart-4)" start={619}> Then the October forecast needs the $86,400 moved into November. I will update it before the close. </TranscriptTurn> </Transcript> )}Anatomy
<Transcript> <TranscriptTurn speaker="Maya Okafor" start={0} color="var(--chart-1)" active onSeek={seek}> <TranscriptWords words={words} currentTime={currentTime} /> </TranscriptTurn></Transcript>Guidance
Use it when
- Use it next to a recording: call reviews, meeting notes, voicemail, interviews.
- Underline unsure words wherever a wrong word costs something: names, amounts, dates.
- Pair it with Waveform and give each speaker the same color in both.
Avoid
- Live dictation. VoiceTranscript in Voice input shows interim words while someone speaks.
- Marking every word below perfect confidence. Pick a threshold that flags a few words per minute.
- Rewriting what people said. A transcript shows the words; a summary goes in its own place.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Render one TranscriptTurn per uninterrupted stretch of one speaker, in time order, with start in seconds from the start of the recording.
- Use TranscriptWords when the recognizer returns word timings, and pass currentTime from the audio element so the current word is marked. Pass confidence when you have it.
- Set active on the turn being played and scroll it into view with block: "nearest" only while the recording plays, never while the person is scrolling.
- Pass onSeek to make each turn's time a button, and seek the audio element to the seconds it receives.
- Reuse the speaker's Waveform color in color. Use chart tokens such as var(--chart-1), not raw hex.
API
TranscriptTurn
| Prop | Type | Default | Description |
|---|---|---|---|
| speaker / start | string, number | Who spoke, and when the turn starts in seconds. | |
| color | string | The speaker's color. Shown as a dot before the name. | |
| active | boolean | false | The turn being played. Exposed as data-active and aria-current. |
| onSeek | (seconds: number) => void | Makes the time a button that plays from the start of the turn. |
TranscriptWords
| Prop | Type | Default | Description |
|---|---|---|---|
| words | TranscriptWordData[] | { text, start, end, confidence? } | |
| currentTime | number | Seconds played. Marks the word being said. | |
| unsureBelow | number | 0.7 | Confidence under which a word is underlined. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add transcript# or, with the registry in components.jsonnpx shadcn@latest add @prodular/transcript