Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp, FileText, LocateFixed, Search, TimerOff, X } from "lucide-react"
import { cn } from "@/lib/utils"

/* --------------------------------------------------------------------------
 * Thresholds of the follow → takeover → return state machine.
 * ----------------------------------------------------------------------- */

/**
 * Every scroll position we write ourselves is recorded in `pinnedTop`. A scroll
 * event reporting anything further than this from that value can only be the
 * reader moving the viewport (wheel, touch, scrollbar, find-in-page, screen

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/audio-transcript.json

Prompt

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; currentTimeMs goes in, onSeek comes out carrying the segment's startMs verbatim. 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 to dangerouslySetInnerHTML, which is the difference between showing <img src=x onerror=…> and running it.

On This Page