Audio Transcript
A timestamped, speaker-labelled transcript that follows the playhead, hands scrolling back the moment the reader takes over, and seeks to the exact start of any line you click or search for.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/audio-transcript.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AudioTranscript" component: a
timestamped transcript that follows a playhead, seeks on click, and can be
searched. It owns no clock and plays nothing — the consumer pushes
`currentTimeMs` in and applies `onSeek` to its own `<audio>` element. The
transcript is the subject; the audio is a controlled input.
Contract
- forwardRef div extending HTMLAttributes<HTMLDivElement>; the ref lands on the
outer card. Merge className with cn().
- TranscriptSegment: { id: string; text: string; startMs?: number;
endMs?: number; speakerId?: string }. TranscriptSpeaker:
{ id: string; name: string; avatarUrl?: string; colorIndex?: 1..5 }.
- Props: segments (required, playback order — never sorted), speakers?,
currentTimeMs? (ms, undefined = static document), onSeek?(ms, segment),
autoScroll? (true), followAnchor? (0.35 — 0 = top of the box, 1 = bottom),
searchable? (true), showTimestamps? (true),
timestampPrecision? ("auto" | "seconds" | "milliseconds"), height? ("24rem"),
label? ("Transcript"), emptyState?.
- EVERYTHING is in milliseconds, including onSeek's first argument. Seconds
would force a float divide on the way in and a float multiply on the way
out, and "seek to exactly this line's start" is the one thing that must be
bit-exact: onSeek reports segment.startMs verbatim, never a re-derived value.
- Segments without a finite startMs are legal. They render as prose instead of
seek targets, and a transcript where NONE of them is timed says so out loud
instead of pretending it can follow anything.
Behavior
- Current line = the last segment whose startMs <= currentTimeMs. Deriving it
from starts only (rather than start..end containment) keeps the last spoken
line lit through a silent gap instead of blinking the highlight off, and
lights nothing at all before the first line. The <li> gets aria-current.
- Auto-follow is a three-state machine: follow → takeover → return.
* follow: when the current line CHANGES (not on every playhead tick — those
arrive 4-60x a second), glide the viewport so the line rests on
followAnchor. The glide is a rAF tween that writes scrollTop every frame
and records each write in `pinnedTop`.
* takeover: any scroll event landing more than 2px from `pinnedTop` is the
reader — wheel, touch, scrollbar, find-in-page, screen reader. Do NOT
"absorb" small deviations by re-pinning to whatever the last event
reported: that lets a slow trackpad glide walk the viewport anywhere two
pixels at a time and never register as a takeover (measured: 30px of
1px-at-a-time scrolling escaped entirely; with a fixed pin it is caught
after 3px). wheel and touchmove ALSO take over directly, before the scroll
they cause is applied, because our own tween writes every frame and would
otherwise overwrite the reader mid-gesture; guard that with "can this
actually scroll in that direction", or a wheel at the end stop switches
following off while nothing visibly moves.
* return: following re-arms when the current line is fully in view again AND
the motion is towards the anchor. Both halves matter. A pixel band instead
of visibility is stepped clean over by one 60px wheel notch (measured: the
reader passed from -38px to +22px in a single notch and never re-armed).
Without the direction test, a 40px nudge that leaves the line visible
re-arms instantly and the next spoken line yanks the reader back.
* Re-arming never scrolls. It only re-baselines the pin; the next line glides
from wherever the reader left the viewport.
* While taken over, a "Follow playback" pill is the explicit way back.
- Clicking a line seeks and re-arms following. A pointer click that ends a
drag-selection is suppressed (event.detail > 0 plus a non-collapsed
selection) so people can copy quotes out of the transcript; keyboard
activation, where detail is 0, is never suppressed.
- Consecutive lines from one speaker are one turn: avatar + name print once, at
the line where the speaker changes. The name is always printed, so the
per-speaker var(--chart-N) tint is decoration, never the only way to tell two
speakers apart.
- Search is case-insensitive, counts every occurrence (not every line), and
steps through them with ▲/▼ or Enter/Shift+Enter. Stepping to a hit seeks to
that line's start, which makes the hit the current line — so auto-follow
lands on the exact position the jump is scrolling to and the two never fight.
When the hit has no timestamp, take over instead, because nothing else will
move the viewport. Zero matches keeps every line listed and says so; that is
a different state from an empty transcript.
* Highlighting builds real <mark> elements from string slices. Never
dangerouslySetInnerHTML: transcripts are model output about user speech,
and a line containing `<img src=x onerror=…>` must render as those
characters (verified: 0 injected nodes through the element path, 1 img +
1 script + a fired handler through an innerHTML build).
* Lowercasing can change a string's length (İ → i̇), which would make the
match offsets address the wrong characters; when it does, that segment
falls back to an exact match rather than slicing text at wrong indices.
- Timestamps: `2:03` under an hour, `1:02:03` over it, both from an explicit
en-US Intl.NumberFormat (Intl.*(undefined) renders Eastern Arabic digits for
some visitors while the column assumes two glyphs). "auto" precision prints
`.mmm` as soon as any segment starts on a fractional second, so a contract
given in milliseconds is never silently rounded to whole seconds.
- Accessibility: aria-current on the current line; the scroll box is a labelled,
tabbable group (an untimed transcript has no buttons at all, so an
unreachable scroll box would be a trap); the search result count is a polite
status. The playhead is deliberately NOT announced — a live region updating
with the audio would make a screen reader unusable during playback.
- Rows are memoised. A 400-line transcript otherwise re-renders every row on
every playhead tick when only two rows changed.
Rendering & styling
- Semantic tokens only: bg-card / border for the shell, bg-primary/10 +
border-primary for the current line, bg-primary/20 and bg-primary +
text-primary-foreground for search hits, bg-muted / text-muted-foreground for
timestamps and chrome, ring-ring for focus. Speaker tints are
var(--chart-1..5) on the avatar ring only — never on text.
- The current line is marked three ways at once (background, left rule, weight)
plus aria-current, so it survives greyscale, colour blindness and a screen
reader.
- wrap-anywhere on the text column is the load-bearing defence against a
64-character build id or a 120-character URL; min-w-0 next to it is a
redundant flex guard. Measured at 375px with both in place the scroll box is
331/331; dropping wrap-anywhere makes it 574/331, i.e. 243px of sideways
scrolling inside the transcript, while dropping min-w-0 alone changes
nothing. Note what does NOT catch that: document.scrollWidth read 365/375 in
every one of those runs, because an overflow-y:auto box computes overflow-x
to auto and swallows the overflow into its own scrollbar. Assert
scrollWidth <= clientWidth per scroll box, not per document.
- The glide is the only animation and prefers-reduced-motion turns it into a
single instant write; following still works, it just doesn't slide.
Customization levers
- followAnchor moves the read line: 0.15 reads like a teleprompter, 0.5 centres
it, 0.8 keeps more of what is coming.
- Drop the toolbar with searchable={false}; drop the left column with
showTimestamps={false} (the lines stay clickable). Both together give a
bare-bones caption panel.
- timestampPrecision="seconds" forces whole seconds on millisecond data;
"milliseconds" forces .mmm on whole-second data (subtitle QA wants this).
- Speakers: swap the avatar for initials-only, or move the tint from the ring
to a left rule per turn — colour is decoration in both cases because the name
is always printed.
- Auto-follow off entirely (autoScroll={false}) turns this into a static,
searchable, clickable transcript document.
- Grouping: render each turn as a bubble instead of a flat list by wrapping the
runs between speaker headers — the turn boundary is already computed.
- For transcripts beyond a few thousand lines, add windowing around the same
row registry (rowNodes) — nothing else in the scroll machine changes.Concepts
- Controlled playhead, exact seeks — the component owns no clock and never plays anything;
currentTimeMsgoes in,onSeekcomes out carrying the segment'sstartMsverbatim. Everything is milliseconds end to end so "jump to this line" lands on the line's start and not a few hundred milliseconds into it. - Follow → takeover → return — auto-scroll runs only when the current line changes, and any scroll position more than 2px from the one the component last wrote is treated as the reader taking over. Measured: after a 200px scroll up, ten consecutive playhead updates moved the viewport 0px; the same script against a build with the takeover test removed dragged the reader 878px.
- The pin never drifts — the follow position is only ever recorded when the component writes it, never re-synced to whatever the last scroll event reported. A pin that absorbed small deviations let 30px of one-pixel-at-a-time scrolling pass unnoticed; a fixed pin caught it after 3px.
- Return means "the line is visible again", not "a pixel band" — a single wheel notch is ~60px and jumps straight over a 24px window; requiring visibility plus motion towards the anchor gives a window people can actually hit, without a 40px nudge instantly re-arming the follow.
- Turns, not rows — consecutive lines from one speaker print the avatar and name once, at the line where the speaker changes, and the speaker's
var(--chart-N)tint sits on the avatar ring rather than on text, so the name (not the colour) is what identifies who is talking. - Structural highlighting — search hits are produced by slicing the string and emitting real
<mark>elements; the transcript text is never handed todangerouslySetInnerHTML, which is the difference between showing<img src=x onerror=…>and running it.