Transcript
A timestamped, speaker-labelled transcript of a recording that follows playback, seeks from each line, and searches with highlighted matches.
- Status
- Since
0.18.0- Accessibility pattern
- labelled scroll region with a list of lines
Last updated
Install
Add Transcript from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/transcriptThe 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
currentTimefrom your player, and seek it inonSeek. - Compose with:
AudioPlayerorVideoPlayerthrough theirmediaRef. Scrolling isMessageScroller's engine.
Anatomy
Transcript is a compound component. Every exported part, with the data-slot it renders
(generated from the canonical source):
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.
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.
Search
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.
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>Read-only
Without onSeek the timestamps are plain text and no row has a button. Without currentTime
no line is current.
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).
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Names the scrollable transcript region. |
children* | React.ReactNode | — | TranscriptSearch (optional) and TranscriptList. |
segments* | TranscriptSegment[] | — | The segments, sorted by start. |
backLabel | string | "Back to current line" | The button that brings a paused list back to the current row. |
batchSize | number | 100 | Rows 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. |
currentTime | number | undefined — no row is current | The playback position in seconds; the row it falls in gets aria-current="true". |
defaultFollow | boolean | true | Initial follow state when uncontrolled. |
emptyState | React.ReactNode | "No transcript yet" | Shown when there are no segments and nothing is loading. |
follow | boolean | undefined — 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 hour | Formats a segment's start for display. Keep it referentially stable on a long transcript. |
loading | boolean | false | Shows skeleton rows while there are no segments, and marks the list busy. |
loadingLabel | string | "Loading transcript…" | Text for the loading state, read by screen readers. |
nowPlayingLabel | string | "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. |
query | string | undefined — uncontrolled, starting empty | Controlled 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) => id | Maps 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
| Attribute | Values |
|---|---|
data-slot | "transcript" |
TranscriptSearch
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes for the search row. |
label | string | "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. |
nextLabel | string | "Next match" | Name of the button that moves to the next match. |
noMatchesLabel | string | "No matches" | Shown and announced when the query matches nothing. |
placeholder | string | "Search…" | Placeholder text in the empty field. |
previousLabel | string | "Previous match" | Name of the button that moves to the previous match. |
Data attributes and CSS variables on TranscriptSearch
| Attribute | Values |
|---|---|
data-slot | "transcript-search" |
TranscriptSpeakers
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes for the chip row. |
inputLabel | string | "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
| Attribute | Values |
|---|---|
data-slot | "transcript-speakers" |
TranscriptList
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes for the list's scroll frame (give it a height, or let it flex). |
speakerInputLabel | string | "Speaker name" | Accessible name of a turn's speaker-name field, used when the Transcript has
onSpeakerRename. |
Data attributes and CSS variables on TranscriptList
| Attribute | Values |
|---|---|
data-slot | "transcript-list" |
data-state | "empty" | "loading" |
Accessibility
- The list is a scroll region named by
aria-labeland reachable with Tab. Its lines are alistoflistitems. The current line hasaria-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
listnamed "Speakers"; each rename button is namedRename {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.
| Key | Action |
|---|---|
| Tab | Move to the search field, the list, and each line's seek button |
| Enter / Space | Play from the focused line's timestamp |
| ↑ ↓ Page Up Page Down Home End | Scroll the list; pauses following |
| Enter (in search) | Next match |
| Shift+Enter (in search) | Previous match |
| Contract | States tested |
|---|---|
| Behaviour | default, current, following, paused, searching, no-matches, empty, loading |
| Accessibility | labeled, list-semantics, aria-current, busy, announced-search-position, label-in-name, focus-preserved, browser-accessibility-test |
| Visual | default, current, hover, focus, highlighted-match, current-match |
Do / Don't
Message Scroller
A chat transcript scroller that follows the live edge only while the reader is, anchors each new turn near the top, and never pulls them away.
Questionnaire
A guided one-question-at-a-time form — fixed choices, a freeform answer, explicit skip, shortcut keys, validation, resume and conditional items.