Design a podcast player with a synced transcript
A transcript panel next to a podcast player that highlights the word being spoken, scrolls along without fighting the reader, seeks when you click a line, finds words in a 3 hour episode and shares a quote at its exact second. Showing text is easy; the hard parts are keeping 30,000 words in sync with audio without re-rendering them, and an auto-scroll that knows when to stop.
The question
Our podcast app already has a player. Design the transcript panel that sits next to it: the text of the episode, with the current words highlighted as they are spoken, the view following along, and the ability to click any line to jump there.
Walk me through where transcripts come from and in what format, how you keep the highlight in sync without making the page slow, how auto-scroll behaves when the user scrolls on their own, how search and sharing work, what changes for a 3 hour episode, and how the transcript serves deaf listeners and search engines.
Real-life examples: Apple Podcasts transcripts, Spotify podcast transcripts, YouTube 'Show transcript' panel, Descript, Snipd, Podcasting 2.0 apps that read the podcast:transcript tag
Requirements: what is the transcript for?
A synced transcript is the written text of an episode, shown next to the player, where the words being spoken right now are highlighted and the text moves along with the audio. Apple Podcasts added this in iOS 17.4 in March 2024, Spotify shows it for many shows, and YouTube has a 'Show transcript' panel under videos that have captions.
Several guides already cover the ground around this one, and we link them instead of repeating them. The audio player guide covers playback itself: the media element, Media Session and the lock screen, saving the position, chapters and playback speed. The video player guide covers captions: WebVTT text tracks, ::cue styling and caption settings.
The Spotify and YouTube guides cover a player that keeps playing while the user moves between pages. The virtualized list guide covers rendering very long lists, and the Medium blog guide covers anchoring highlights to text and link previews for shared quotes. Here we design only the transcript panel and how it talks to the player.
Start with questions. Each answer changes a part of the design; the middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Where do transcripts come from? | Some shows publish one in their RSS feed; for the rest we generate one on our servers with speech-to-text | Format parsing, a normalizing step, and quality we do not control |
| Do we have word-level timings or only line timings? | Word timings for generated transcripts, often only line timings for published ones | Highlight per word or per line; the UI must handle both |
| How long are episodes? | Median about 45 minutes, long tail up to 3 to 4 hours | Up to about 30,000 words in the DOM; rendering strategy |
| Which devices? | Mobile web and desktop, mid-range Android phones included | A tight budget for work per frame while audio plays |
| Can users edit or correct the text? | Creators can correct their own transcript in a creator tool; listeners only read | Versioning and cache invalidation, but no editor in the listener UI |
| Search? | Inside the current episode only | Search across episodes is a server search problem, out of scope |
| Sharing? | Yes: share a quote with a link that starts playback at that second | A URL format and a landing behaviour |
| Must it work offline? | Yes, for downloaded episodes | The transcript is stored with the audio |
| Must search engines index it? | Yes, for episode pages | Server-rendered HTML, not only a client fetch |
Functional requirements
- Show the transcript of the playing episode next to (desktop) or under (mobile) the player, with speaker names and paragraph breaks.
- Highlight the current word (when word timings exist) and the current line, in sync with playback, including after seeks and speed changes.
- Follow along: keep the current line in view while playing. Stop following when the user scrolls or selects text, and offer 'Return to current position'.
- Click or press Enter on a line to seek the audio there. Selecting text to copy must not seek.
- Search inside the transcript: count matches, step between them, show them on the timeline, seek to one.
- Share a quote: copy a link that opens the episode at that second, with the quote visible.
Non-functional requirements
- Smooth: following the audio costs under 1 ms of main thread work per frame, so it never causes jank (a dropped or late frame) while the user scrolls.
- Fast to show: the panel is readable within 1 second of opening the episode page on a 4G phone; the transcript must not delay the audio starting.
- Scales to a 3 hour episode (about 30,000 words, about 1 MB of raw JSON) without a long freeze on open.
- Accessible: the transcript is a full text alternative for deaf and hard-of-hearing users, usable with a keyboard and a screen reader, and the moving highlight never spams the screen reader.
- Indexable: episode pages serve the transcript as HTML so search engines can read it.
- Works offline for downloaded episodes.
Out of scope
- Playback itself, the lock screen and resume position: see the audio player guide.
- Captions drawn over video: see the video player guide.
- Search across all episodes: a server-side search index, a different problem.
- The creator's transcript editor: we only define the data it produces.
- Translation of transcripts into other languages: it reuses the same data model with one transcript per language.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the transcript panel the most? Select all that apply.
What makes a synced transcript hard
Five properties of this problem drive every decision later. Keep them in mind; each later chapter answers one of them.
- It is long. People speak about 150 words a minute, so a 45 minute episode is about 7,000 words and a 3 hour one about 27,000 to 30,000 (an estimate; fast talkers go higher). That is more text than most web pages ever render, and every word is a click target.
- It moves on a clock you do not own. The browser plays the audio outside your JavaScript. Your code only learns the time by reading
currentTimeor by hearing atimeupdateevent, which the HTML spec says to fire every 15 to 250 ms and browsers in practice send about every 250 ms. The highlight must look smooth while the clock ticks in steps. - It changes forever while the user does other things. A 3 hour episode means about 30,000 highlight changes. If each one costs a React re-render of the whole list, the page janks for 3 hours, and the user is reading and scrolling the whole time.
- The user and the app both want to control the scroll. Follow-along scrolls the panel to the spoken line. The reader scrolls back to re-read a sentence. If the app wins, the reader is yanked away mid-sentence.
- The data is messy. Transcripts arrive as WebVTT, SRT, JSON or plain text, from the creator or from a machine. Machine timings drift by a fraction of a second, speaker labels can be wrong, and some lines have no word timings at all.
Notice what is not hard: fetching the transcript and drawing paragraphs. A junior answer spends 20 minutes there. A strong answer spends it on the clock, the highlight cost, the scroll fight and the 3 hour episode.
The wireframe above shows the parts we will design: the player bar with a timeline that also shows search matches, the search box, the follow-along toggle, the transcript body with speakers and timestamps, the highlighted word and line, and the 'Return to current position' button that appears only after the user scrolls away.
Think first
The video player already shows captions from a WebVTT track. Why not reuse the browser's caption rendering for the transcript panel?
Captions show one or two cues at a time, drawn by the browser over the video. A transcript shows the whole text at once, in the page flow, and lets you scroll, search, select, click and share it. The browser's caption renderer does none of that.
You can still reuse the data path: a track element exposes its cues through TextTrack.cues, and each cue has startTime, endTime and text. Some apps read cues that way. But you get only what a VTT file carries, the cues exist only once the media element loads the track, and speaker names arrive as raw voice tags inside text. So most transcript panels fetch a richer file and parse it themselves.
Where transcripts come from, and the data the client gets
Before the UI, we need text with times. There are two sources: the creator publishes a transcript, or we make one.
Published transcripts: the podcast:transcript tag
Podcasts are distributed as RSS feeds. The Podcasting 2.0 namespace, maintained by Podcast Index, adds a podcast:transcript tag to each episode item. It has a required url and type (a MIME type), an optional language, and an optional rel="captions" that marks a file with time codes meant for captions. An episode can list several, for example one WebVTT and one HTML.
Big apps treat these files as one input among several. Apple Podcasts generates its own transcript for new episodes in supported languages, and shows the creator's file from the feed only when the creator opts in, in Podcasts Connect. Spotify also generates transcripts and lets creators upload a corrected file.
<item>
<title>Episode 212: Shipping on Fridays</title>
<enclosure url="https://cdn.example.fm/212.mp3" type="audio/mpeg" length="58211840"/>
<podcast:transcript url="https://cdn.example.fm/212.vtt"
type="text/vtt" language="en" rel="captions"/>
<podcast:transcript url="https://cdn.example.fm/212.json"
type="application/json"/>
</item>
The namespace docs describe four file formats. They differ in how precise their times are, which they call fidelity:
| Format | Times | Speakers | Notes |
|---|---|---|---|
WebVTT (text/vtt) | Per cue, a line or two of text | Yes, with a voice span: <v Ana> | The W3C caption format. The namespace docs recommend it if you support only one, and say Apple Podcasts ingests it. |
SRT (application/x-subrip) | Per cue | Only as a 'Name:' prefix by convention | Older caption format; commas in times (00:00:02,760) |
JSON (application/json) | Per segment; a segment can be a single word | Yes, a speaker field | version plus a segments array of startTime, endTime, speaker, body. One word per segment gives word timings. |
HTML (text/html) | Optional start time per paragraph | Yes, in cite | Low fidelity: readable and searchable, no sync |
Plain text (text/plain) also appears in the wild. Note a practical trap from the namespace docs: if a browser fetches the file from the podcast host directly, that host must send CORS headers. Many do not. We avoid the problem entirely by fetching the file on our server during ingest.
Generated transcripts: speech-to-text on the server
Most episodes ship no transcript, so we generate one. Automatic speech recognition (ASR) turns audio into text: OpenAI's open Whisper models, or cloud services like AWS Transcribe, Google Cloud Speech-to-Text, Deepgram and AssemblyAI. Four outputs matter to us:
- Word timestamps. The OpenAI transcription API returns them when you ask for
verbose_jsonwith word granularity. Whisper's own word times come from its attention weights and can be off by a few hundred milliseconds or more. A second pass called forced alignment (used by tools like WhisperX) lines each known word up with the sound and is usually much tighter. Treat any accuracy number as an estimate and measure on your own audio. - Speaker labels. Finding who spoke when is called diarization. It gives anonymous labels (
SPEAKER_00,SPEAKER_01) that a creator, or the show's metadata, maps to names. It makes mistakes when people talk over each other. - Confidence. Many engines score each word from 0 to 1. We keep it so the creator tool can flag likely errors; we do not show it to listeners.
- Punctuation and paragraphs. Raw ASR is a stream of words. Sentences come from punctuation the model adds, paragraphs from speaker changes and pauses longer than about 1.5 seconds (a tuning value, not a standard).
This runs once per episode, on a GPU worker, minutes after the episode is published. It never runs in the listener's browser: a 3 hour file is 100+ MB of audio, and the result is the same for every listener.
Decision What format does the browser receive?
What we pick: Normalize on the server. A podcast app shows thousands of shows with every format and quality, so one clean contract between ingest and the UI is worth the pipeline. The client gets one JSON shape whether the text came from the creator or from ASR, with words when we have them and only segments when we do not.
When a creator corrects a word in the creator tool, we store a new transcript version and bump it in the episode metadata. The client's transcript URL includes that version, so the old file can be cached for a year and the new one is a new URL. This is the content-hashed URL pattern from the HTTP caching lessons, applied to data.
Check your understanding · Exercise 2
A show publishes only an SRT file. What does a listener get in our design? Select all that apply.
The data model: segments, words and arrays built for lookup
The transcript has two levels. A segment is a run of speech by one speaker: a sentence or a short paragraph, with a start and an end time. A word is one token inside a segment, with its own start and end when we have word timings. Paragraphs in the UI are groups of segments by the same speaker.
The naive wire format is one object per word: {"text": "hello", "start": 754.12, "end": 754.48, "speaker": "Ana"}. That is about 60 bytes per word, so a 30,000 word episode is about 1.8 MB of JSON before compression, and the speaker name repeats 30,000 times. Parsing creates 30,000 objects that the garbage collector must track.
Decision How do we shape the transcript JSON?
What we pick: Segments with text and flat word times. It renders without reshaping, compresses well, and the same shape covers line-only transcripts. Binary is not worth it until profiling says parse time matters.
{
"episodeId": "ep_212",
"version": 3,
"language": "en",
"source": "asr",
"durationMs": 10812000,
"speakers": { "s0": "Ana Ruiz", "s1": "Ben Okafor" },
"chapters": [{ "title": "Why Fridays", "startMs": 0 }],
"segments": [
{ "id": 0, "sp": "s0", "start": 0, "end": 4180,
"text": "Welcome back to the show.",
"w": [0, 420, 430, 790, 800, 1010, 1020, 1240, 1250, 1900] },
{ "id": 1, "sp": "s1", "start": 4300, "end": 9020,
"text": "Thanks, glad to be here." }
]
}
Segment 1 has no w: that is how a line-only transcript looks, or a segment where alignment failed. The UI checks for w per segment, not per transcript, so mixed quality works.
On the client: an index of sorted numbers
React renders segments from the JSON as is. For syncing, we build a second structure once, right after parse: flat typed arrays of start times. A typed array (Uint32Array) is one block of memory with no per-item objects, so 30,000 starts take 120 KB and searching it is fast.
Ingest sorts segments by start time and resolves overlapping speech so that word starts never go backwards. That sorted order is what makes binary search possible in the next chapter.
type TimeIndex = {
segStart: Uint32Array; // start ms of each segment
wordStart: Uint32Array; // start ms of every word, all segments
wordEnd: Uint32Array;
wordSeg: Uint32Array; // which segment each word belongs to
segFirstWord: Int32Array; // index of its first word, -1 if none
};
function buildIndex(t: Transcript): TimeIndex {
const n = t.segments.reduce((s, g) => s + (g.w?.length ?? 0) / 2, 0);
const ix = {
segStart: new Uint32Array(t.segments.length),
wordStart: new Uint32Array(n), wordEnd: new Uint32Array(n),
wordSeg: new Uint32Array(n), segFirstWord: new Int32Array(t.segments.length),
};
let k = 0;
t.segments.forEach((g, s) => {
ix.segStart[s] = g.start;
ix.segFirstWord[s] = g.w ? k : -1;
for (let i = 0; g.w && i < g.w.length; i += 2, k++) {
ix.wordStart[k] = g.w[i]; ix.wordEnd[k] = g.w[i + 1]; ix.wordSeg[k] = s;
}
});
return ix;
}
| Data | Where it lives | Why there |
|---|---|---|
| Transcript JSON | Server cache (TanStack Query or similar), keyed by episode id and version | Fetched data with a lifetime; shared by the panel and the episode page |
| Time index | A memo next to the transcript, rebuilt only when the version changes | Derived data; never stored or sent |
| Current time | Read from the audio element, not stored in React state | Changes 4 to 60 times a second; storing it would re-render on every tick |
| Active segment and word | A small store outside React plus refs | Only the old and new lines need to change |
| Follow mode, search query, current match | Panel component state | Local UI state |
Shared position (#t=754) | The URL | Must survive a link being sent to a friend |
| Downloaded transcript | Cache Storage or IndexedDB with the audio | Offline listening |
Check your understanding · Exercise 3
Why is the current playback time not kept in React state in this design? Select all that apply.
Finding the active word: which clock, and how fast
At any moment we need to answer: which segment and which word contain the current time? Two parts: when to ask, and how to answer quickly.
When to ask: timeupdate or requestAnimationFrame
The media element fires timeupdate while playing. MDN describes it as between about 4 and 66 times a second, depending on load; in practice browsers fire it about every 250 ms during normal playback, and you should not count on more. Four updates a second is fine for a line highlight. For words it is not: a fast speaker says 3 to 4 words a second, so with 250 ms steps the highlight lands late, skips short words like 'a' and 'to', and moves in visible jerks.
Decision What drives the highlight updates?
What we pick: Both clocks, each for its job. A requestAnimationFrame loop runs only while playing and the panel is visible, and does the word highlight. timeupdate, seeked and ratechange remain as a safety net that also updates the line, so a paused or hidden panel still knows where it is. Every run starts by checking if the active word changed; if not, it does nothing.
One subtle point: never compute the time yourself as 'start time plus elapsed'. Always read audio.currentTime. It already accounts for playbackRate (1.5x moves the clock 1.5 times faster), buffering stalls and seeks. The audio player guide owns the element; the transcript only reads from it.
How to answer: binary search, with a shortcut
The naive answer is words.findIndex(w => t >= w.start && t < w.end). On a 30,000 word episode near the end, that is 30,000 comparisons, 60 times a second: 1.8 million per second, plus an object per word. It works on a laptop and drains a phone.
Because wordStart is sorted, binary search finds the last word that started at or before time t in about 15 steps (log2 of 30,000). And most frames need no search at all: if the time is still inside the current word, or inside the next one, we are done. Search only after a jump.
function lastStartAtOrBefore(starts: Uint32Array, t: number): number {
let lo = 0, hi = starts.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (starts[mid] <= t) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
function activeWord(ix: TimeIndex, tMs: number, prev: number): number {
// Fast path: still in the same word or moved to the next one.
if (prev >= 0 && tMs >= ix.wordStart[prev]) {
if (prev + 1 >= ix.wordStart.length || tMs < ix.wordStart[prev + 1]) return prev;
if (prev + 2 >= ix.wordStart.length || tMs < ix.wordStart[prev + 2]) return prev + 1;
}
return lastStartAtOrBefore(ix.wordStart, tMs); // after a seek
}
Decide what 'active' means in a gap. Between two words, during a pause, lastStartAtOrBefore keeps the previous word lit. That is usually what people want: the highlight rests on the last spoken word instead of blinking off. If the gap is long, more than 2 seconds of music, you can dim it by also checking t against wordEnd.
- Seek: the user drags the scrubber to 1:42:10. On
seeked, run the full binary search for both word and segment, then update the highlight and scroll (if following). - Rate change: nothing special.
currentTimealready moves faster; at 2x the fast path simply advances more often. - Stall: while buffering,
currentTimestops, the active word stays, and the loop does nothing. - Timing offset: ASR word times tend to land a little late or early. A per-transcript
offsetMsfrom ingest, or a small fixed lead of about 50 to 100 ms (a tuning value), makes the highlight feel on time.
Check your understanding · Exercise 4
A user listening at 2x reports the word highlight lags and skips words. Which changes fix it? Select all that apply.
Painting the highlight without re-rendering 30,000 words
We know the active word. Now we have to show it. This is where most first versions fail, and where the interviewer probes hardest.
The naive React version keeps activeWord in state at the top of the panel and renders each word as className={i === activeWord ? "on" : ""}. Every change re-renders the whole tree. Even with memo on each word, React still walks and compares 30,000 children per change.
On a mid-range phone that can take tens of milliseconds, several times a second, for three hours. Scrolling stutters, and the battery drains. The runtime performance lesson explains why: the frame budget at 60 Hz is about 16 ms, and the browser needs part of it for style, layout and paint.
Decision How do we move the highlight from one word to the next?
What we pick: Custom Highlight API where CSS.highlights exists, class toggling as the fallback. Both make the per-change cost constant. The line highlight uses a class toggle in both cases, because the line is a real element we also mark with aria-current and scroll to. React renders the text once per transcript version and never again during playback.
const wordHl = new Highlight();
CSS.highlights.set("tx-word", wordHl);
function paintWord(seg: HTMLElement, from: number, to: number) {
const text = seg.firstChild as Text; // the segment's single text node
const r = new Range();
r.setStart(text, from);
r.setEnd(text, to);
wordHl.clear();
wordHl.add(r);
}
/* CSS:
::highlight(tx-word) { background-color: Mark; color: MarkText; }
*/
Character offsets come from tokenizing each segment's text once, the same way the server did, and storing where each token starts. Keep the segment's text in one text node by rendering it as a single string. React creates a separate text node for each adjacent expression, like {a}{b}, and a stray element in the middle splits the node too; either one breaks the offsets.
Ana, 0:00 (now): Welcome back to the show, today we talk about shipping on Fridays.
Ben, 0:04: I used to think deploying on a Friday was a terrible idea.
Ana, 0:08: Most teams did, because a bad deploy ruins the weekend.
In the lab above, pick each strategy and watch how many elements each word change touches. Re-rendering touches every word every time the active word changes. Class toggling touches two, and the Highlight API touches none: the browser repaints the old and new word directly. The lab also shows the auto-scroll behaviour from the next chapter.
Check your understanding · Exercise 5
Why does the Custom Highlight API version still mark the active line with a class instead of a second highlight? Select all that apply.
Auto-scroll that respects the reader
Follow-along means the panel scrolls so the spoken line stays in view. The naive version calls line.scrollIntoView({ behavior: "smooth", block: "center" }) every time the line changes. Three things go wrong:
- It fights the reader. The user scrolls up to re-read a sentence, and 3 seconds later the next line change yanks them back. The reader loses their place.
- It kills selection. The user drags to select a quote; the text moves under the pointer and the selection grows or jumps.
- It scrolls the wrong thing.
scrollIntoViewscrolls every scrollable ancestor until the element is visible, including the page itself. On mobile, where the transcript sits under the player, the whole page jumps and the player bar scrolls away.
Follow mode is a small state machine
Model follow-along as three states: following (we scroll), detached (the user took over; we do not scroll and show 'Return to current position'), and off (the user turned the 'Follow along' toggle off). Text selection is not a fourth state: it is one more way to become detached.
The hard part is telling the user's scroll from ours. A scroll event fires for both. Three signals, used together:
- Listen to intent, not result.
wheel,touchstart,pointerdownon the scrollbar, and scrolling keys (PageUp,PageDown, arrows,Home,End,Space) inside the panel can only come from a person. Any of them while following means detach. - Mark our own scrolls. Before a programmatic scroll, set
programmatic = trueand record the target. When scrolling settles (ascrollendevent where supported, or noscrollfor about 150 ms as a fallback), clear it. Ascrollthat arrives while the flag is false, and moves away from our target, is the user. - Selection: on
selectionchange, if the selection is not collapsed and lies inside the panel, detach. Copying a quote should never fight the scroll.
Decision How do we scroll the active line into view?
What we pick: Scroll the panel ourselves, and only when the line leaves the comfort band. Use behavior: "smooth" normally and "auto" (instant) when prefers-reduced-motion: reduce matches or when the jump is longer than about two screens, because a long smooth scroll is slow and makes some people dizzy.
function followLine(panel: HTMLElement, line: HTMLElement) {
if (follow.state !== "following") return;
const top = line.offsetTop - panel.scrollTop; // panel is position: relative
const h = panel.clientHeight;
if (top > h * 0.3 && top + line.offsetHeight < h * 0.7) return; // comfortable
const target = Math.max(0, line.offsetTop - h * 0.35);
const far = Math.abs(target - panel.scrollTop) > h * 2;
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
follow.programmatic = true;
follow.target = target;
panel.scrollTo({ top: target, behavior: reduce || far ? "auto" : "smooth" });
}
The 'Return to current position' button sits pinned over the bottom of the panel when detached. It shows an arrow up or down, depending on whether the spoken line is above or below the view, which tells the user where the audio is.
Clicking it jumps to the line, returns to following, and moves focus to the active line so keyboard users land in the right place. If the user scrolls back to the active line on their own, re-attach automatically after it has stayed in the band for a second or so.
Think first
The user is detached, reading something 20 minutes earlier, and clicks a sentence there to hear it. What should follow mode do?
Seek to that sentence and switch back to following. The user just told us where they want to be, and the clicked line is already in view, so following starts with no jump at all.
The opposite case matters too: if the user seeks with the scrubber or a keyboard shortcut while detached, we also re-attach and jump the panel to the new line. A seek is a strong signal that they want the text to match the audio.
Check your understanding · Exercise 6
Which events should switch follow mode from following to detached? Select all that apply.
Search inside the episode
Users search a transcript to find the moment they half remember: 'where did she mention the book?'. Browser find (Ctrl+F) already exists, but it knows nothing about time: it cannot show where matches fall in the episode, cannot seek, and is hidden in a menu on phones. So we build in-panel search and keep browser find working too (the next chapter explains how).
Matching: fold the text once, search a flat string
The naive search loops over 30,000 word objects comparing lowercase strings on every keystroke. Instead, when the transcript loads, build one folded copy of each segment's text: lowercase, accents removed (normalize("NFD") then strip combining marks), curly quotes turned into straight ones. Folding must keep the string the same length, or offsets in the folded copy stop matching the real text; when a character expands (like German 'ß' to 'ss'), keep a small offset map.
Then each query is a fold of the query plus indexOf in a loop over about 3,000 segment strings. On a phone that takes a few milliseconds even for 3 hours of text. Debounce input by about 150 ms so fast typing does not search for every letter.
type Match = { seg: number; from: number; to: number; ms: number };
function search(folded: string[], q: string): Match[] {
const needle = fold(q.trim());
if (needle.length < 2) return [];
const out: Match[] = [];
folded.forEach((text, seg) => {
for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + 1)) {
const w = wordAtChar(seg, i);
out.push({ seg, from: i, to: i + needle.length,
ms: w >= 0 ? ix.wordStart[w] : ix.segStart[seg] });
}
});
return out; // already in time order, because segments are
}
Showing matches: in the text and on the timeline
- In the text: all matches go in one
Highlightregistered astx-match, the current one intx-match-currentwith a stronger color. No DOM changes, so even 400 matches cost little to show. The fallback wraps matches inmarkelements for the rendered segments only. - Counter and stepping: 'Match 3 of 12' with Previous and Next buttons, Enter and Shift+Enter in the box. Stepping scrolls the panel to the match and detaches follow mode, because the user is now browsing, not listening.
- Seeking: stepping does not move the audio, since jumping the audio on each Next would be jarring. Clicking the match text, or pressing a 'Play from match' button next to the counter, seeks and re-attaches.
- On the timeline: a thin marker per match on the player's scrubber, at
left = ms / durationMsof its width. A 600 px scrubber over 3 hours gives 18 seconds per pixel, so close matches collide. Group matches per pixel bucket and draw one marker per bucket. - For screen readers: the counter is in a polite live region (
aria-live="polite"), announced after typing pauses: '12 matches, showing 3'.
- Ana: Welcome to Ship It Weekly, recorded at a café in Lisbon.
- Ben: Today the question is simple: should you deploy on a Friday?
- Ana: Our first Friday deploy broke checkout for two hours.
- Ben: That story is why most teams freeze code before the weekend.
- Ana: Small changes and feature flags made deploys boring for us.
- Ben: Boring is the goal, a deploy should feel like nothing.
- Ana: We also practice rollbacks every week, not only in emergencies.
- Ben: Let's take a short break and get another coffee from the cafe.
- Ana: Welcome back, a listener asked about Friday on-call pay.
- Ben: On-call should be paid, and quiet if the deploys are small.
- Ana: Measure it: count incidents after each Friday release.
- Ben: That is all for today, thanks for listening, and deploy safely.
In the lab above, type a word to see matches in the text and markers on the timeline. Step through them, click a word to seek, and copy a share link for the playhead's moment. Notice that stepping moves the current match but not the playhead.
Check your understanding · Exercise 8
Search for 'cafe' should find 'Café' in the transcript. Which statements about the folding approach are right? Select all that apply.
The 3 hour episode: 30,000 words in the page
Everything so far assumed the text is in the page. For a 30 minute show it is easy: about 4,500 words in about 500 segments. A 3 hour interview has about 30,000 words in about 3,000 segments. With a span per word that is over 30,000 elements; Lighthouse's DOM size audit has long warned at around 800 elements and failed at around 1,400.
Every style recalculation, every layout after a resize, and the first render all scale with that count. On a mid-range phone, the first render of such a page can take several hundred milliseconds (an estimate; measure it on your target devices). The Custom Highlight API already removed the per-word spans, leaving 3,000 segment elements with one text node each. That is better but still large, so the question is whether to keep all of it in the DOM.
Decision How do we render a transcript of 3,000 segments?
What we pick: All text in the DOM with content-visibility: auto on chunks. For a transcript, being findable and readable is the product: it is the accessible alternative to the audio and the text that search engines index. Virtualization buys speed by giving that up. We keep virtualization in reserve only for extreme cases, such as a 10 hour live stream archive, and then we must ship our own search, since browser find stops working.
content-visibility: auto keeps the text in the DOM, so browser find, Text Fragments, screen readers and highlight ranges still see all of it..tx-chunk {
content-visibility: auto;
contain-intrinsic-size: auto 1500px;
}
.tx-seg[aria-current="true"] { background-color: var(--cf-color-warning-subtle); }
::highlight(tx-word) { background-color: Mark; color: MarkText; }
::highlight(tx-match) { text-decoration: underline 2px; }
Check your understanding · Exercise 9
Your team virtualized the transcript to make 3 hour episodes fast. What breaks? Select all that apply.
Architecture, loading and the component API
Now put the pieces together. The panel has three jobs that change at very different speeds: the text (once per version), the sync (up to 60 times a second) and the UI controls (when the user acts). The boundaries follow those speeds.
| Part | Responsibility | Why it is its own piece |
|---|---|---|
| Player (from the audio player guide) | Owns the audio element; exposes currentTime, duration, seek(), and events | One owner of playback; the transcript only reads and asks |
| Transcript loader | Fetches the transcript JSON by episode id and version; caches it | Network and caching rules in one place |
| Time index | Typed arrays of starts, token offsets, folded text | Derived once; shared by sync, seek and search |
| Sync engine | The frame loop and event listeners; computes active word and line; tells painters | The only code running 60 times a second, kept outside React |
| Painters | Highlight API or class toggle for the word; class and aria-current for the line | Swappable by browser support |
| Follow controller | The follow state machine and scrolling | Has its own events and timing; easy to test alone |
| Transcript body | Server-rendered segments in chunks; delegated click and key handlers | Static, large, never re-rendered by React |
| Search bar, follow toggle, return button, share popover | Small client components | The only parts that re-render on user input |
Loading: when to fetch, and how much
Decision When does the transcript load?
What we pick: Server-render the text, fetch the word times when they are needed. On other pages, where the mini-player plays while the user browses (the Spotify guide covers that persistent player) and the panel opens as a drawer, the panel fetches the full JSON when opened. Both JSON files have the version in the URL and are served with Cache-Control: public, max-age=31536000, immutable, so a repeat visit reads them from the cache.
Rough sizes for a 3 hour episode, all estimates: text about 170 KB, and word times about 500 KB raw, because each of 60,000 numbers takes up to 8 digits. Compression shrinks the text to roughly 50 to 70 KB and the times to roughly 150 to 200 KB. Delta-encoding the times (store the gap to the previous number, usually 2 or 3 digits) cuts the times file by about half or more.
Do not hydrate 3,000 segments
The other loading cost hides in React itself. Hydration is React walking the server HTML and attaching its components and listeners to it. Hydrating the transcript as 3,000 React components can cost a phone hundreds of milliseconds (an estimate), all spent on text that never changes.
The transcript body is static, so render it as server HTML (a Server Component in React's model, or plain HTML from the server) and attach only the delegated listeners from earlier chapters. The interactive parts, the search box, follow toggle and return button, are small client components around it. The SSR lesson explains this split.
Offline: the transcript travels with the audio
When a user downloads an episode for the train (the audio player guide covers storing the audio), the download job also stores the transcript JSON in the same place, under the same versioned URL, so the service worker can answer the panel's fetch from Cache Storage. Storing it costs under 1 MB next to 100+ MB of audio. If the creator later corrects the transcript, the next online sync of the library sees a new version and replaces the stored file in the background.
The component API
type TranscriptPanelProps = {
episodeId: string;
version: number;
player: PlayerHandle; // currentTime, duration, seek(s), on(ev)
initialHtml?: boolean; // body already server-rendered in place
follow?: "on" | "off"; // initial follow mode, user can change
highlight?: "auto" | "line" | "none"; // "auto": words when available
onSeek?: (ms: number, from: "click" | "key" | "search") => void;
onShare?: (link: string, quote: string) => void;
onSearch?: (query: string, count: number) => void;
};
type PlayerHandle = {
readonly currentTime: number; // seconds, always live
readonly duration: number;
seek(seconds: number): void;
on(ev: "play" | "pause" | "seeked" | "ratechange", fn: () => void): () => void;
};
Check your understanding · Exercise 10
Why does the design server-render the transcript text instead of fetching it only when the user presses play? Select all that apply.
Accessibility and search engines
For most listeners the transcript is a nice extra. For deaf and hard-of-hearing people it is the episode. WCAG success criterion 1.2.1, Audio-only (Prerecorded), at level A, requires a text alternative that presents equivalent information for prerecorded audio, and a transcript is that alternative.
'Equivalent' means more than words: it names who is speaking and notes meaningful sounds such as [laughter] or [music]. ASR output often drops those, so the creator tool should make adding them easy.
Structure a screen reader can move through
- Headings per chapter. If the episode has chapters, each starts with a heading (
h3under the episode'sh2, for example). Screen reader users jump between headings to skim a 3 hour transcript the way sighted users scroll. - Speakers as text. Each paragraph starts with the speaker's name in visible text, not only a color or avatar. Color alone fails WCAG 1.4.1.
- Times as text. A
timeelement with adatetimeduration (PT12M34S) shows '12:34' and gives each line a clear label for the roving-tabindex keyboard model. - The language. Set
langon the transcript container to the transcript's language, so a screen reader pronounces it correctly even when the app UI is in another language.
Do not narrate the highlight
A tempting mistake is to make the highlight 'accessible' by putting the transcript in an aria-live region. The screen reader would then try to read every word change, several times a second, on top of the audio. The follow-along highlight is a visual aid; a screen reader user already hears the audio or reads at their own pace.
So: no live region on the body, the line marker is only aria-current="true" (read when the user reaches that line, never announced on its own), and focus never moves while following. The only live announcements are the ones the user asked for: search counts, and 'Following' or 'Stopped following' when they press the toggle.
- A clear toggle. 'Follow along' is a
buttonwitharia-pressed, visible at the top of the panel, so users who find moving text distracting can turn it off for good (remember the choice in local storage). - Reduced motion. Under
prefers-reduced-motion: reduce, scroll instantly and drop the line's fade. - Contrast. Aim for at least 3:1 between the word highlight and the page background so it is easy to see, and keep the highlighted text at 4.5:1 against the highlight color (WCAG 1.4.3). System colors
MarkandMarkTextalso adapt in forced colors mode. - Text size. The panel uses
remunits, so text scales to 200 percent (WCAG 1.4.4), and it reflows at a 320 CSS pixel width without horizontal scrolling (WCAG 1.4.10).
Search engines
A 45 minute episode's transcript is about 7,000 words of real, specific text, often the best content on the page. Because we server-render it, crawlers see it as page content. Add structured data so machines know what the text is: a schema.org PodcastEpisode whose associatedMedia is an AudioObject.
The transcript property belongs to AudioObject and VideoObject, not to the episode itself. Google lists no podcast rich result among its supported structured data, so do not expect a special search result; the main gain is the indexed text. The internationalization and SEO lesson covers per-language URLs for translated transcripts.
{
"@context": "https://schema.org",
"@type": "PodcastEpisode",
"name": "Episode 212: Shipping on Fridays",
"url": "https://pods.example/ep/212",
"datePublished": "2026-10-01",
"partOfSeries": { "@type": "PodcastSeries", "name": "Ship It Weekly" },
"associatedMedia": {
"@type": "AudioObject",
"contentUrl": "https://cdn.example.fm/212.mp3",
"duration": "PT3H0M12S",
"transcript": "Welcome back to the show. Thanks, glad to be here..."
}
}
For long episodes, putting all 30,000 words in JSON-LD doubles the page weight. Include the first few paragraphs, or leave transcript out; the full text is already in the HTML.
Check your understanding · Exercise 11
A screen reader user says the transcript panel 'never stops talking' while the episode plays. What is the likely cause and fix? Select all that apply.
Running it in the interview
This is a component question with a hidden performance core. Interviewers use it to see whether you notice that a list of paragraphs becomes a real-time system once audio plays. A plan for 45 minutes:
| Minutes | What you do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope questions | Episode length, word or line timings, sources, search, sharing, offline, SEO. Say the 3 hour number out loud. |
| 5 to 10 | Wireframe and the hard parts | The panel next to the player; name the hard things: clock, highlight cost, scroll fight, length, messy data. |
| 10 to 17 | Data and formats | Normalize on the server; segments with text and word times; typed array index built once. |
| 17 to 27 | Sync and highlight | Frame loop plus timeupdate, fast path plus binary search, Highlight API with class fallback. Give the cost per change. |
| 27 to 35 | Follow mode and seek | The follow state machine with its three states; detect user scroll by intent; selection-safe click; roving tabindex. |
| 35 to 42 | Long episodes, a11y, loading | content-visibility over virtualization and why; no live region; SSR text plus lazy times. |
| 42 to 45 | Wrap up | Search, sharing with ?t= and Text Fragments, offline; what you would measure. |
Draw the wireframe first, then the data model, then the tick sequence. Those three pictures carry most of the answer, and each invites the next question. Leave the ingest pipeline as one box unless the interviewer asks about it; this is a frontend round.
Follow-up questions to expect
Think first
How would this change for a video, like YouTube's transcript panel?
Almost nothing in the panel. The player handle exposes the same currentTime, seek() and events. The differences are layout (the panel sits beside or below a video) and that captions over the video and the transcript now show the same text, so they should come from the same normalized data to avoid two versions disagreeing.
Think first
A creator corrects 40 words while thousands of people are listening. What happens to them?
Nothing breaks. Each listener holds version 3 in memory and in the HTTP cache under a versioned URL. The episode metadata now says version 4. Listeners get it on the next page load or the next metadata refresh. We never patch the text under someone who may be selecting a quote. If a listener has the panel open for hours, a quiet 'Transcript updated' notice with a reload button is enough.
Think first
What would you measure in production to know the panel is healthy?
Long tasks and Interaction to Next Paint (INP) on episode pages, split by episode length, to catch the 3 hour case. The share of sessions where users detach follow mode and how often they press 'Return to current position', which shows whether auto-scroll annoys. Search usage and zero-result rates. Click-to-seek counts. And the timing offset: if users often click a word and then seek back a second, the word times are late.
Think first
Could this work for a live show with a transcript arriving as people speak?
Yes, with changes. Segments arrive over a WebSocket or server-sent events and are appended. ASR rewrites its last few words as it hears more, so the newest segment is provisional and replaced in place until it is final. Follow mode pins to the bottom like a chat log. The time index grows by appending, which keeps it sorted. Search runs over what has arrived so far.
Check your understanding · Exercise 12
Which of these would a strong candidate bring up without being asked? Select all that apply.
Summary
- Normalize every source, from RSS
podcast:transcriptfiles to ASR output, into one JSON shape: segments with speaker, start, end and text, and optional word times. The client never parses VTT or SRT. - Never store the playback time in React state. Read
currentTime, drive words from arequestAnimationFrameloop while playing, and keeptimeupdate,seekedandratechangeas the safety net. - Find the active word with a fast path (same or next word) and binary search on a sorted typed array after seeks: two comparisons on most frames, about 15 after a jump.
- Make each highlight change cost constant: the CSS Custom Highlight API paints a Range with zero DOM writes; toggling a class on two elements is the fallback.
- Follow mode is a state machine. Detect the user's scroll by intent (wheel, touch, keys, selection), mark your own scrolls, scroll only the panel and only outside a comfort band, and offer 'Return to current position'.
- Click to seek with one delegated handler that ignores clicks ending a selection; keyboard users get one tab stop with arrows inside.
- For long episodes, keep all text in the DOM with
content-visibility: autoon chunks so Ctrl+F, Text Fragments, screen readers and highlights keep working; virtualize only in extreme cases. - The transcript is an accessibility feature and indexable content: server-render it, never narrate the highlight, and fetch word times lazily with versioned, immutable URLs.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Podcasting 2.0 namespace: the transcript tag The url, type, language and rel attributes of podcast:transcript.
- Podcasting 2.0 namespace: transcript formats The four formats the podcast:transcript tag points to, including the JSON segments shape.
- WebVTT: The Web Video Text Tracks Format (W3C) Cue timing and voice spans for speakers.
- Apple introduces transcripts for Apple Podcasts Word highlighting, search, tap to play; iOS 17.4.
- Apple Podcasts for creators: Transcripts on Apple Podcasts Automatic transcripts, and how creators opt in to their own files from the feed.
- MDN: HTMLMediaElement timeupdate event Why timeupdate alone is too coarse for word highlighting.
- MDN: CSS Custom Highlight API Highlight, HighlightRegistry and ::highlight().
- MDN: content-visibility Skipping rendering of off-screen content while keeping it searchable.
- web.dev: content-visibility Measured rendering savings and contain-intrinsic-size.
- MDN: Text fragments Linking to a quote inside a page.
- W3C: Understanding SC 1.2.1 Audio-only and Video-only (Prerecorded) Why a transcript is required for prerecorded audio.
- schema.org: PodcastEpisode Structured data for episodes; transcript lives on the AudioObject.
Concepts behind it
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.