Skip to content
Component installs need the registry setup
VegaStack Design

Transcript

A timestamped, speaker-labelled transcript of a recording that follows playback, seeks from each line, and searches with highlighted matches.

Status
stable
Since
0.18.0
Accessibility pattern
labelled scroll region with a list of lines

Last updated

Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

Raj Patel

Marketing came in under plan, mostly because two events moved online.

Now playing
Mei Chen

Did the online events reach the same number of people?

Raj Patel

More, actually. Registrations were up by about a third.

Ana Ruiz

Then we should keep one of them online next quarter as well.

Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

Raj Patel

I can send them the numbers today and collect answers by Friday.

Ana Ruiz

Good. Next item: the hiring plan for the support team.

Mei Chen

We have two open roles, and the first interviews are next week.

Ana Ruiz

Let's make sure the budget covers both before we make offers.

Raj Patel

It does, with the savings from events. I'll confirm with finance.

Ana Ruiz

Great. That's everything on the list. Thanks, everyone.

Install

Add Transcript from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/transcript

The same command installs the registry items it composes: @vegastack/message-scroller, @vegastack/item, @vegastack/button, @vegastack/panel-search, @vegastack/use-announcer, @vegastack/skeleton, @vegastack/media-player-controls, @vegastack/use-media-query, @vegastack/input, @vegastack/editable-cell.

Usage

import {
  Transcript,
  TranscriptList,
  TranscriptSearch,
} from "@/components/ui/transcript";

<Transcript
  aria-label="Meeting transcript"
  segments={segments}
  speakerName={(id) => people[id].name}
  currentTime={time}
  onSeek={(seconds) => seekTo(seconds)}
  className="h-96"
>
  <TranscriptSearch />
  <TranscriptList />
</Transcript>;

Each segment is { id, start, end?, speaker, text }, with start and end in seconds and the segments sorted by start. Each turn is a header — the time, the speaker's coloured dot and name — with the text full width below it, the same layout on a phone and a desktop. The row playing at currentTime is marked current, and while the transcript is following it stays centred. A reader who scrolls pauses following, and "Back to current line" brings it back.

Long transcripts mount progressively: the first batchSize turns (100) at once and the rest in idle-time batches, with the current line and the current search match always mounted before anything scrolls to them.

Scope

  • Owns: the current line, following it, pausing on a user scroll, seek buttons, search with <mark> highlights, speaker colours and chips, progressive mounting, and the empty and loading states.
  • Does not own: playback or the clock. Pass currentTime from your player, and seek it in onSeek.
  • Compose with: AudioPlayer or VideoPlayer through their mediaRef. Scrolling is MessageScroller's engine.

Anatomy

Transcript is a compound component. Every exported part, with the data-slot it renders (generated from the canonical source):

Transcript — data-slot="transcript"
TranscriptList — data-slot="transcript-list"
TranscriptSearch — data-slot="transcript-search"
TranscriptSpeakers — data-slot="transcript-speakers"

Examples

Current line and seek

Each timestamp is a button named "Play from 0:15" that calls onSeek with the segment's start. Here it moves a local clock, so clicking a line makes it the current one.

Rows opt out of MessageScrollerItem's content-visibility: auto, so every row is laid out at mount. A skipped row reports an estimated height, and a follow or search jump measured against that estimate lands past its line once the real rows render; laying them all out costs once, and a currentTime tick still re-renders at most two rows.

Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

Raj Patel

Marketing came in under plan, mostly because two events moved online.

Now playing
Mei Chen

Did the online events reach the same number of people?

Raj Patel

More, actually. Registrations were up by about a third.

Ana Ruiz

Then we should keep one of them online next quarter as well.

Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

Raj Patel

I can send them the numbers today and collect answers by Friday.

Ana Ruiz

Good. Next item: the hiring plan for the support team.

Mei Chen

We have two open roles, and the first interviews are next week.

Ana Ruiz

Let's make sure the budget covers both before we make offers.

Raj Patel

It does, with the savings from events. I'll confirm with finance.

Ana Ruiz

Great. That's everything on the list. Thanks, everyone.

With audio

Read currentTime from the player's <audio> element on timeupdate, and set it in onSeek. The transcript follows the playing line until you scroll it yourself.

0:00
0:00
Now playing
Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

Raj Patel

Marketing came in under plan, mostly because two events moved online.

Mei Chen

Did the online events reach the same number of people?

Raj Patel

More, actually. Registrations were up by about a third.

Ana Ruiz

Then we should keep one of them online next quarter as well.

Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

Raj Patel

I can send them the numbers today and collect answers by Friday.

Ana Ruiz

Good. Next item: the hiring plan for the support team.

Mei Chen

We have two open roles, and the first interviews are next week.

Ana Ruiz

Let's make sure the budget covers both before we make offers.

Raj Patel

It does, with the savings from events. I'll confirm with finance.

Ana Ruiz

Great. That's everything on the list. Thanks, everyone.

TranscriptSearch wraps each match in <mark>. Enter and Shift+Enter, or the arrow buttons, move between matches. Each move scrolls the match into view, pauses following, and announces its position ("2 of 5"). Pass query and onQueryChange to control the query yourself, for example to keep it in the URL.

0:00
Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

0:07
Raj Patel

Marketing came in under plan, mostly because two events moved online.

0:15
Mei Chen

Did the online events reach the same number of people?

0:22
Raj Patel

More, actually. Registrations were up by about a third.

0:30
Ana Ruiz

Then we should keep one of them online next quarter as well.

0:37
Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

0:45
Raj Patel

I can send them the numbers today and collect answers by Friday.

0:52
Ana Ruiz

Good. Next item: the hiring plan for the support team.

1:00
Mei Chen

We have two open roles, and the first interviews are next week.

1:07
Ana Ruiz

Let's make sure the budget covers both before we make offers.

1:15
Raj Patel

It does, with the savings from events. I'll confirm with finance.

1:22
Ana Ruiz

Great. That's everything on the list. Thanks, everyone.

Speakers and rename

Pass onSpeakerRename(id, name) to the Transcript and the speaker name in every turn header becomes an inline EditableCell: click the name to edit it, Enter or leaving the field saves, Escape cancels. The rename is by speaker id, so every turn with that speaker takes the new name. Store the name and return it from speakerName. The search field is capped at max-w-sm, so it never stretches across the toolbar. TranscriptSpeakers (the old chip row) is deprecated.

<Transcript
  aria-label="Meeting transcript"
  segments={segments}
  speakerName={(id) => names[id] ?? id}
  onSpeakerRename={(id, name) => renameSpeaker(id, name)}
>
  <TranscriptSearch />
  <TranscriptList />
</Transcript>
0:00
Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

0:07
Raj Patel

Marketing came in under plan, mostly because two events moved online.

0:15
Mei Chen

Did the online events reach the same number of people?

0:22
Raj Patel

More, actually. Registrations were up by about a third.

0:30
Ana Ruiz

Then we should keep one of them online next quarter as well.

0:37
Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

0:45
Raj Patel

I can send them the numbers today and collect answers by Friday.

0:52
Ana Ruiz

Good. Next item: the hiring plan for the support team.

1:00
Mei Chen

We have two open roles, and the first interviews are next week.

1:07
Ana Ruiz

Let's make sure the budget covers both before we make offers.

1:15
Raj Patel

It does, with the savings from events. I'll confirm with finance.

1:22
Ana Ruiz

Great. That's everything on the list. Thanks, everyone.

Read-only

Without onSeek the timestamps are plain text and no row has a button. Without currentTime no line is current.

0:00
Ana Ruiz

Thanks for joining. Let's start with the quarterly budget review.

0:07
Raj Patel

Marketing came in under plan, mostly because two events moved online.

0:15
Mei Chen

Did the online events reach the same number of people?

0:22
Raj Patel

More, actually. Registrations were up by about a third.

0:30
Ana Ruiz

Then we should keep one of them online next quarter as well.

0:37
Mei Chen

Agreed. I'd like the regional teams to weigh in before we decide.

Loading and empty

loading shows skeleton lines and marks the list busy. With no segments and nothing loading, the list shows emptyState ("No transcript yet" by default).

Loading transcript…
No transcript yet

API Reference

PropTypeDefaultDescription
aria-label*string—Names the scrollable transcript region.
children*React.ReactNode—TranscriptSearch (optional) and TranscriptList.
segments*TranscriptSegment[]—The segments, sorted by start.
backLabelstring"Back to current line"The button that brings a paused list back to the current row.
batchSizenumber100Rows mounted in the first paint; the rest mount in idle-time batches of the same size. The current row and the current search match are always mounted.
currentTimenumberundefined — no row is currentThe playback position in seconds; the row it falls in gets aria-current="true".
defaultFollowbooleantrueInitial follow state when uncontrolled.
emptyStateReact.ReactNode"No transcript yet"Shown when there are no segments and nothing is loading.
followbooleanundefined — uncontrolled, starting at `defaultFollow`Controlled follow: while true the current row is kept centred.
formatTime((seconds: number) => string)m:ss, or h:mm:ss past an hourFormats a segment's start for display. Keep it referentially stable on a long transcript.
loadingbooleanfalseShows skeleton rows while there are no segments, and marks the list busy.
loadingLabelstring"Loading transcript…"Text for the loading state, read by screen readers.
nowPlayingLabelstring"Now playing"Visually hidden text on the current row.
onFollowChange((follow: boolean) => void)—Called when follow pauses (a user scroll, a search jump) or resumes (the back button).
onQueryChange((query: string) => void)—Called with the new query as the reader types in TranscriptSearch.
onSeek((seconds: number) => void)—Called with a segment's start when its timestamp is activated. Without it, timestamps are plain text and no row has a button.
onSpeakerRename((id: string, name: string) => void)—Called when a speaker is renamed — by clicking the name in any turn of TranscriptList (every turn with that id takes the new name). Without it, names are read-only and the chips have no rename button.
querystringundefined — uncontrolled, starting emptyControlled search query. Matches are wrapped in <mark>.
seekLabel((time: string) => string)(time) => `Play from ${time}`The seek button's accessible name, given the formatted time. Keep the visible time inside it (WCAG 2.5.3).
speakerName((id: string) => string)(id) => idMaps a speaker id to the name shown on its rows. Keep it referentially stable (module level or useCallback) on a long transcript — it is a render dependency of every row.

Data attributes and CSS variables on Transcript

AttributeValues
data-slot"transcript"

TranscriptSearch

PropTypeDefaultDescription
classNamestring—Classes for the search row.
labelstring"Search transcript"The search field's accessible name.
matchLabel((index: number, total: number) => string)(i, n) => `${i} of ${n}`The position text, shown and announced after each move.
nextLabelstring"Next match"Name of the button that moves to the next match.
noMatchesLabelstring"No matches"Shown and announced when the query matches nothing.
placeholderstring"Search…"Placeholder text in the empty field.
previousLabelstring"Previous match"Name of the button that moves to the previous match.

Data attributes and CSS variables on TranscriptSearch

AttributeValues
data-slot"transcript-search"

TranscriptSpeakers

PropTypeDefaultDescription
classNamestring—Classes for the chip row.
inputLabelstring"Speaker name"Accessible name of the rename field.
renameLabel((name: string) => string)(name) => `Rename ${name}`Accessible name of a chip's rename button, given the speaker's name.

Data attributes and CSS variables on TranscriptSpeakers

AttributeValues
data-slot"transcript-speakers"

TranscriptList

PropTypeDefaultDescription
classNamestring—Classes for the list's scroll frame (give it a height, or let it flex).
speakerInputLabelstring"Speaker name"Accessible name of a turn's speaker-name field, used when the Transcript has onSpeakerRename.

Data attributes and CSS variables on TranscriptList

AttributeValues
data-slot"transcript-list"
data-state"empty" | "loading"

Accessibility

  • The list is a scroll region named by aria-label and reachable with Tab. Its lines are a list of listitems. The current line has aria-current="true" and starts with a visually hidden "Now playing".
  • The line is not a live region, so playback never talks over the recording. The one live region announces search positions ("1 of 3", "No matches").
  • A seek button's name, "Play from 0:15", contains its visible time. Time updates never move focus.
  • When "Back to current line" scrolls back, focus moves to the list so it does not fall to the page.
  • Speaker chips are a list named "Speakers"; each rename button is named Rename {name}, and the rename field returns focus to it when it closes. Speaker colour is never the only cue: every turn names its speaker.
  • Follow and search scroll smoothly, and instantly under prefers-reduced-motion.
KeyAction
TabMove to the search field, the list, and each line's seek button
Enter / SpacePlay from the focused line's timestamp
↑ ↓ Page Up Page Down Home EndScroll the list; pauses following
Enter (in search)Next match
Shift+Enter (in search)Previous match
ContractStates tested
Behaviourdefault, current, following, paused, searching, no-matches, empty, loading
Accessibilitylabeled, list-semantics, aria-current, busy, announced-search-position, label-in-name, focus-preserved, browser-accessibility-test
Visualdefault, current, hover, focus, highlighted-match, current-match

Do / Don't

Do
Use Transcript for the text of a recording, with currentTime from the player and onSeek wired back to it.
Don't
Build a recording's transcript from MessageScroller and Message — a chat thread pins to its newest message and has no current line.
Do
Use Timeline for a record of events, such as an activity feed or an audit trail.
Don't
Use Transcript for events that are not speech, or announce every line as it plays.

On this page