cf.completefrontend
My progress
SYSTEM DESIGN · #43

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.

QuestionAnswer we assumeWhat 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-textFormat 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 onesHighlight 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 hoursUp to about 30,000 words in the DOM; rendering strategy
Which devices?Mobile web and desktop, mid-range Android phones includedA 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 readVersioning and cache invalidation, but no editor in the listener UI
Search?Inside the current episode onlySearch across episodes is a server search problem, out of scope
Sharing?Yes: share a quote with a link that starts playback at that secondA URL format and a landing behaviour
Must it work offline?Yes, for downloaded episodesThe transcript is stored with the audio
Must search engines index it?Yes, for episode pagesServer-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.

  1. 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.
  2. 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 currentTime or by hearing a timeupdate event, 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.
  3. 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.
  4. 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.
  5. 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.

Wireframe of the episode page. At the top, the player bar shows episode 212 at 12:34 of 3:00:12 and a timeline with three search match markers. Below it, a search box reads fridays, 3 of 12, next to a Follow along toggle that is on. The transcript panel shows Ana Ruiz at 12:30 with the active line, we ship on Fridays now, and it is fine, then Ben Okafor at 12:41 saying wait, every Friday. A Return to current position button sits at the bottom of the panel.episode pageEp 212 12:34 / 3:00:12search: fridays, 3 of 12Follow along: onAna Ruiz, 12:30we ship on Fridays now, and it is fineBen Okafor, 12:41wait, every Friday?Return to current position
Every part maps to a later chapter: the timeline markers to search, the toggle and the return button to follow mode, the active line and word to sync and highlight. The return button only appears after the user scrolls away.

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?

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.

html
<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>
An episode in an RSS feed offering two transcript files.

The namespace docs describe four file formats. They differ in how precise their times are, which they call fidelity:

FormatTimesSpeakersNotes
WebVTT (text/vtt)Per cue, a line or two of textYes, 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 cueOnly as a 'Name:' prefix by conventionOlder caption format; commas in times (00:00:02,760)
JSON (application/json)Per segment; a segment can be a single wordYes, a speaker fieldversion plus a segments array of startTime, endTime, speaker, body. One word per segment gives word timings.
HTML (text/html)Optional start time per paragraphYes, in citeLow 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_json with 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.

The transcript ingest pipeline. The RSS feed's podcast:transcript file goes to an ingest worker that parses VTT, SRT, JSON or HTML. If none is published, the episode audio goes to a GPU speech-to-text worker that produces words, aligns them and labels speakers. Both feed a normalizer that writes one JSON shape with a version number to a store behind a CDN with immutable URLs, which is the only thing the client fetches.RSS feedpodcast:transcriptEpisode audioMP3, 100+ MBIngest workerparse VTT, SRT, JSONASR worker (GPU)words, align, speakersNormalizerversioned JSONStore + CDNimmutable URLsnone publishedthe client fetches only this JSON
Two sources, one output. The client never parses VTT or SRT and never hits the podcast host, so CORS and format quirks stay on the server.

Decision What format does the browser receive?

Pass the original file through

The client fetches whatever the feed points to: VTT, SRT, JSON or HTML.

The client ships four parsers and picks one by type.

Pros

  • No server work for published transcripts.
  • Always the creator's latest file.

Cons

  • Four parsers in the bundle, each with edge cases.
  • CORS failures on hosts we do not control.
  • Different fidelity means different UI code paths.

Normalize on the server to one JSON shape

Ingest parses every source, plus ASR output, into one schema: segments with speakers and times, and words when available.

The client fetches only that, from our CDN.

Pros

  • One small parser-free client: it is already JSON.
  • Same origin or our CDN, so no CORS surprises.
  • We can add derived data, like paragraph breaks and a version number.

Cons

  • An ingest pipeline to build and run.
  • When a creator fixes their file, we must re-ingest and invalidate caches.

Use a native track element and read TextTrack.cues

Attach the VTT to the audio element as a track with kind="metadata".

Read cues and listen to cuechange.

Pros

  • The browser parses VTT and fires cuechange when the active cue changes.
  • Very little code.

Cons

  • Cue level by default: speaker names arrive as raw voice tags inside each cue's text, and SRT or JSON sources must be converted to VTT anyway.
  • Ties the transcript to the media element's lifecycle; nothing to show before the audio loads.

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?

One object per word

An array of { text, start, end, speaker, confidence }, like the Podcasting 2.0 JSON with one word per segment.

Pros

  • Simple to read and write.
  • Matches a public format.

Cons

  • Largest raw payload and the most objects after parse; compression hides much of the size, but not the parse cost.
  • Segments and speakers must be rebuilt on the client.

Segments with text, plus a flat list of word times

Each segment carries speaker, start, end, text, and w: a flat array of word start and end times in milliseconds.

Word number i of a segment is the i-th whitespace-separated token of its text.

Pros

  • Less than half the raw size, about 0.8 MB for 3 hours (an estimate): text is stored once, times as integers.
  • About 3,000 segment objects after parse instead of 30,000 word objects.
  • Segments arrive ready to render.
  • Works with or without words: just omit w.

Cons

  • Our own format, so it needs a schema and a version.
  • Tokens must split the same way on server and client.

Binary (Protocol Buffers or a custom typed array blob)

Times packed as integers, text as one string table.

Pros

  • Smallest and fastest to decode.

Cons

  • Needs a decoder in the bundle and is hard to debug.
  • After Brotli, JSON of option 2 is close enough in size.

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.

json
{
  "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." }
  ]
}
GET /v1/episodes/ep_212/transcript?v=3 (times in ms; w holds start,end pairs per word)

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.

ts
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;
}
Built once per transcript version, in a few milliseconds even for 30,000 words.
The transcript data model. Segment 0 by Ana Ruiz runs from 0 to 4180 ms and has text and word times; segment 1 by Ben Okafor runs from 4300 to 9020 ms with no word times. The words of segment 0, Welcome, back, to, the, show, start at 0, 430, 800, 1020 and 1250 ms; the word the is active. The client builds flat arrays: wordStart holds those five times, wordSeg holds 0 for each, segStart holds 0 and 4300, and segFirstWord holds 0 and minus 1.segment 0: Ana Ruiz0 to 4180 ms, text + wsegment 1: Ben Okafor4300 to 9020, no wWelcome0back430to800the1020show.1250wordStart043080010201250wordSeg00000segStart04300segFirstWord0-1sorted starts: binary search, about 15 steps for 30,000 words
React renders the segments as they arrive. Syncing uses only the flat, sorted arrays, so one binary search over them answers 'which word is at this time?' in about 15 steps for 30,000 words.
DataWhere it livesWhy there
Transcript JSONServer cache (TanStack Query or similar), keyed by episode id and versionFetched data with a lifetime; shared by the panel and the episode page
Time indexA memo next to the transcript, rebuilt only when the version changesDerived data; never stored or sent
Current timeRead from the audio element, not stored in React stateChanges 4 to 60 times a second; storing it would re-render on every tick
Active segment and wordA small store outside React plus refsOnly the old and new lines need to change
Follow mode, search query, current matchPanel component stateLocal UI state
Shared position (#t=754)The URLMust survive a link being sent to a friend
Downloaded transcriptCache Storage or IndexedDB with the audioOffline 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?

timeupdate events only

On each event, read currentTime, find the active word, update the highlight.

Pros

  • No work when paused; no loop to manage.
  • Keeps firing in background tabs, where nobody sees it anyway.

Cons

  • About 4 updates a second: words lag up to 250 ms and short words are skipped.
  • Fine for lines, poor for words.

A requestAnimationFrame loop while playing

On play, start a loop that reads currentTime every frame (60 per second on most screens) and stops on pause or ended.

Pros

  • Smooth word highlight, about one frame late at most.
  • The browser pauses it in hidden tabs, so it costs nothing there.

Cons

  • Runs 60 times a second, so each run must be tiny.
  • Must be started and stopped correctly or it leaks.

Timers scheduled from word times

After each update, setTimeout until the next word's start, scaled by playbackRate.

Pros

  • Exactly one wake-up per word.

Cons

  • Timers drift and get throttled; buffering pauses and seeks break the schedule.
  • Every rate change, stall and seek must cancel and reschedule.

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.

ts
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
}
Index of the last item whose start is at or before t, or -1 before the first.

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.

One frame of the sync loop as a sequence diagram. The frame loop reads currentTime from the audio element and gets 754.31 seconds. It asks the time index for the active word, passing the previous one, and gets word 1834 by the fast path. Only if the word changed does it tell the painter to paint word 1834, and only if the line changed does it call followLine on the follow controller.Frame loopaudioTimeIndexPainterFollowcurrentTime?754.31 sactiveWord(t)word 1834paint word 1834followLine()
Most frames stop after the lookup: the word has not changed, so nothing is painted. Only a line change reaches the follow controller, every few seconds, never per frame. React is not on this diagram at all.
  • 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. currentTime already moves faster; at 2x the fast path simply advances more often.
  • Stall: while buffering, currentTime stops, 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 offsetMs from 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?

Re-render from React state

activeWord in state or context; every word reads it and picks its class.

Pros

  • Idiomatic React; easy to read.

Cons

  • Work grows with the number of words, on every change.
  • Hard to keep under 1 ms per frame past a few hundred words.

Toggle a class on two elements

Words render once, each as a span with a data-i index. Keep an array of span refs, or query by index.

On change: remove the class from the previous span, add it to the new one. React never re-renders.

Pros

  • Two DOM writes per change, whatever the length.
  • Works in every browser; can use any CSS, including mark-like backgrounds.

Cons

  • 30,000 span elements cost memory and initial render time.
  • Code outside React touches the DOM, so it must agree with React about which nodes exist (virtualization makes that harder).

CSS Custom Highlight API

Render each segment as plain text, no per-word spans.

On change, set a Range over the word's characters and register it with CSS.highlights.set("tx-word", new Highlight(range)). Style it with ::highlight(tx-word).

Pros

  • Zero DOM changes: no elements added, no classes, no style recalculation for the tree.
  • Far fewer elements: one text node per segment instead of a span per word.
  • Same tool can paint search matches as a second highlight.

Cons

  • Only some properties apply: color, background-color, text-decoration, text-shadow and a few more, not layout or padding.
  • Ranges are not focusable or clickable; click-to-seek needs another way to find the word under the pointer.
  • Supported in Chrome 105+, Safari 17.2+ and Firefox 140+ (2025): check CSS.highlights and fall back.

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.

ts
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; }
*/
Highlight word k of segment s. charStart comes from a per-segment table of token offsets.

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.

Elements touched per word change: re-render all 81, toggle class 2, Custom Highlight API 0. Current strategy: Re-render all words. So far 0 word changes touched 0 elements. Follow mode is following, with 0 automatic scrolls.elements touched per word change in this 81 word samplere-render all: 8190,000 per secondtoggle class: 26 per secondHighlight API: 00 per secondfor 30,000 words at 3 words a secondword changes: 0touched: 0follow: followingauto scrolls: 0
Press Play, then try each strategy and scroll the panel while it plays.

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:

  1. 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.
  2. It kills selection. The user drags to select a quote; the text moves under the pointer and the selection grows or jumps.
  3. It scrolls the wrong thing. scrollIntoView scrolls 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.

Follow mode as a state machine with three states. The page opens in following. When the user scrolls by wheel, touch or keys, or selects text, it moves to detached, which shows the Return to current position button. Pressing that button, seeking, or clicking a line returns to following. Turning the toggle off from either state moves to off, and turning it on again returns to following. When detached and the active line stays in view for about a second, it re-attaches.page opensdetached, line back in viewfor about 1 s: re-attachfollowingwe scroll the paneldetachedshows Return buttonuser scrolls or selectsReturn button, seek, or click a lineofftoggle is offtoggle offtoggle offtoggle on
Selection is not a separate state: it is one more way to become detached. Every way back to following is an explicit user action, except the gentle re-attach when the user scrolls back to the spoken line on their own.

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, pointerdown on 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 = true and record the target. When scrolling settles (a scrollend event where supported, or no scroll for about 150 ms as a fallback), clear it. A scroll that 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?

scrollIntoView on every line change

line.scrollIntoView({ block: "center", behavior: "smooth" })

Pros

  • One line of code.
  • Handles nested layouts.

Cons

  • Also scrolls the page and any other scrollable ancestor.
  • Moves the text every few seconds, even when the line is already comfortably visible.

Set the panel's own scrollTop, only when needed

Read the line's offset within the panel. If it is outside a comfort band (say the middle 30 to 70 percent of the panel), call panel.scrollTo({ top, behavior }) to put it at about 35 percent from the top.

Otherwise do nothing.

Pros

  • Only the panel moves, never the page.
  • Text moves in occasional steps instead of every line, which is calmer to read.
  • Behaviour easy to tune per layout.

Cons

  • More code: offsets, the band, and resize handling.
  • Reading offsetTop forces layout if the DOM changed; do it once per line change, not per frame.

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.

ts
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" });
}
Called when the active line changes, never per frame.

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?

Check your understanding · Exercise 6

Which events should switch follow mode from following to detached? Select all that apply.

Click to seek, copy without seeking, and share a quote

Clicking text to hear it is the feature people love most. The naive version puts onClick={() => seek(word.start)} on each word span. It works, then breaks three ways: selecting text to copy also fires click at the end of the drag and jumps the audio; 30,000 handlers are a lot of closures; and keyboard users cannot reach any of it.

One delegated handler that respects selection

Put a single click listener on the panel (event delegation). It finds the segment from event.target.closest("[data-seg]"). Before seeking, it checks the selection: if getSelection().isCollapsed is false, the user was selecting, so do nothing.

To find the word under the pointer without per-word elements, ask the browser for the caret position at the click point. The standard method is document.caretPositionFromPoint; older Chrome and Safari versions only have the non-standard caretRangeFromPoint, so feature-detect both. Turn the character offset into a word with the token offsets and seek to that word's start, or to the segment start when there are no word times.

ts
panel.addEventListener("click", (e) => {
  if (!getSelection()?.isCollapsed) return;          // user is copying text
  const seg = (e.target as Element).closest<HTMLElement>("[data-seg]");
  if (!seg) return;
  const s = Number(seg.dataset.seg);
  const charAt = offsetAt(e.clientX, e.clientY, seg); // null if unknown
  const w = charAt == null ? -1 : wordAtChar(s, charAt);
  const ms = w >= 0 ? ix.wordStart[w] : ix.segStart[s];
  player.seek(ms / 1000);
  follow.attach();                                    // see previous chapter
});
Delegated click to seek. offsetAt wraps the two caret APIs.
Click to seek as a sequence diagram. The user clicks the word Fridays in the panel. The panel calls seek 754 on the player and at once lights the word and switches to following. The player asks the audio host for a byte range. Normally the host replies 206 Partial Content and the player resumes, firing timeupdate back to the panel. On failure the request errors, the player shows buffering and retries.UserPanelPlayerAudio hostclick 'Fridays'seek(754.0)word lit, followingGET, Range: bytes206 Partial Contentplaying, timeupdateor: error, retry
The highlight moves before the audio does: we trust the requested time. If the range request fails, the audio player's retry logic owns the fix, and the transcript simply keeps showing the word the user picked.

The seek can fail. Seeking far ahead in a stream that is not buffered fires waiting while the browser fetches that byte range. The highlight moves at once (we trust the requested time), the player shows its buffering state, and if the request errors, the audio player's retry logic takes over. The transcript must not wait for the audio before moving: the user clicked a word and should see it lit immediately.

Keyboard: one tab stop, arrows inside

A transcript with 3,000 lines must not be 3,000 tab stops: a keyboard user would need thousands of key presses to get past it. Use a roving tabindex (see the keyboard navigation and ARIA patterns lessons): the panel is one tab stop, the focused line has tabindex="0" and every other line tabindex="-1".

Up and Down move between lines, Home and End jump to the start and end, Enter or Space seeks to the focused line. When the user tabs into the panel, focus lands on the active line, not the first one. Each line's accessible name includes its time and speaker, like '12:34, Ana Ruiz: we ship on Fridays now'.

Sharing a quote at its second

Selecting text shows a small 'Share quote' button near the selection. It builds a link that opens the episode at the quote's first word. The Medium blog guide covers the selection toolbar and link previews in depth; here we only need the link. Two standards help:

  • Media Fragments define #t=754 (or a range, #t=754,760) on a media URL: the browser starts the audio element at that time. We use the same idea on our page URL, but as a query parameter, ?t=754, because the server never sees the hash. With the time in the query, the server can render the right paragraph, the share card and the page title for that moment. This is the URL as state pattern.
  • Text Fragments add #:~:text=we%20ship%20on%20Fridays to a page URL. Supporting browsers (Chrome, Edge, Safari 16.1+, Firefox 131+) scroll to that text and highlight it on arrival. It only works if the text is in the DOM when the page loads, which is one more reason to server-render the transcript.
text
https://pods.example/ep/212?t=754#:~:text=we%20ship%20on%20Fridays

server:  renders the episode page with the transcript HTML
         og:description = the quote, og:title = "Ep 212 at 12:34"
browser: scrolls to and highlights the quote (text fragment)
client:  sets audio.currentTime = 754 after loadedmetadata,
         shows "Play from 12:34" (no autoplay with sound
         without a user gesture), starts in following mode
A shared quote link, and what the page does with it.

Copying text also deserves care. Listen for copy on the panel and put clean text on the clipboard: no timestamps or hidden labels pulled in from the line markup, and optionally the episode name and a link appended.

Check your understanding · Exercise 7

While playing, a user drags to select a sentence. The text scrolls under the pointer, and when they let go the audio jumps to where the drag ended. Which changes fix these problems? 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?

Render everything

All segments in the DOM, nothing special.

Pros

  • Simplest. Ctrl+F, Text Fragments, screen reader browsing and highlights all just work.

Cons

  • Slow first render and slow layout on resize for long episodes.
  • Every font load or panel resize re-lays out all 3,000 segments.

Virtualize the list

Render only the segments in view plus a buffer, as in the virtualized list guide.

Measure variable heights; keep spacer elements for the rest.

Pros

  • Constant DOM size, about 40 segments, whatever the length.
  • Fastest first render.

Cons

  • Browser find cannot find text that is not in the DOM: Ctrl+F silently misses most of the episode.
  • Text Fragments in shared links fail if the quote is not rendered.
  • Screen reader users lose the ability to read through the whole text; highlight ranges die when their nodes unmount.
  • Variable heights make jumping to 2:41:00 jumpy and complex.

Everything in the DOM, with content-visibility: auto on chunks

Group segments into chunks of about 50 and give each chunk content-visibility: auto with contain-intrinsic-size: auto 1500px.

The browser skips style, layout and paint for chunks far off screen, but keeps them in the DOM.

Pros

  • Most of the rendering savings of virtualization.
  • Content stays in the DOM, so Ctrl+F, Text Fragments and assistive technology can reach it, and ranges stay valid.
  • One CSS rule; no measuring code. Baseline since September 2024 (Chrome 85, Firefox 125, Safari 18).

Cons

  • DOM memory still grows with length.
  • Placeholder sizes are guesses until a chunk has been rendered once, so the scrollbar can shift a little after a long jump.
  • In Safari 18, find-in-page could not see text inside skipped chunks; Safari 26 fixed it. Test the browsers you support.

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.

Two ways to render a 3,000 segment transcript. Left, virtualized: spacers stand in for missing segments and only segments 1201 to 1203 exist, so the DOM holds about 40 segments and browser find for Fridays finds 1 of 12. Right, content-visibility auto: chunk 1 and chunk 60 are skipped by the renderer but still in the DOM, chunks 24 and 25 are rendered, so the DOM holds all 3,000 segments and browser find finds 12 of 12.virtualizedspacer, not in the DOMseg 1201seg 1202seg 1203content-visibilitychunk 1: skippedchunk 24: renderedchunk 25: renderedchunk 60: skippedDOM: about 40 segmentsCtrl+F finds 1 of 12DOM: all 3,000 segmentsCtrl+F finds 12 of 12
Both skip most rendering work. Only content-visibility: auto keeps the text in the DOM, so browser find, Text Fragments, screen readers and highlight ranges still see all of it.
css
.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; }
Chunks the browser can skip when far away. auto remembers the real size after the first render.

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.

The transcript panel's architecture. Inside the client panel, a row of small React components: SearchBar, FollowToggle, ReturnButton and SharePopover. Below them the TranscriptBody of server HTML in chunks, the Painters using the Highlight API, and the FollowCtrl state machine. At the bottom of the panel the Loader with its query cache feeds the TimeIndex of typed arrays, which feeds the SyncEngine running a frame loop plus events. The SyncEngine drives the Painters and the FollowCtrl. Outside the panel, the transcript CDN serves versioned JSON to the Loader, and the Player gives time and events to the SyncEngine.Transcript panel (client)SearchBarFollowToggleReturnButtonSharePopoverTranscriptBodyserver HTML, chunksPaintersHighlight APIFollowCtrlstate machineLoaderquery cacheTimeIndextyped arraysSyncEngineframe loop + eventsTranscript CDNversioned JSONPlayercurrentTime, seek()fetchtime, events
Three speeds, three layers: small React controls change on user input, the sync engine runs up to 60 times a second outside React, and the text and index change once per transcript version. Clicks in the body call the player's seek().
PartResponsibilityWhy it is its own piece
Player (from the audio player guide)Owns the audio element; exposes currentTime, duration, seek(), and eventsOne owner of playback; the transcript only reads and asks
Transcript loaderFetches the transcript JSON by episode id and version; caches itNetwork and caching rules in one place
Time indexTyped arrays of starts, token offsets, folded textDerived once; shared by sync, seek and search
Sync engineThe frame loop and event listeners; computes active word and line; tells paintersThe only code running 60 times a second, kept outside React
PaintersHighlight API or class toggle for the word; class and aria-current for the lineSwappable by browser support
Follow controllerThe follow state machine and scrollingHas its own events and timing; easy to test alone
Transcript bodyServer-rendered segments in chunks; delegated click and key handlersStatic, large, never re-rendered by React
Search bar, follow toggle, return button, share popoverSmall client componentsThe only parts that re-render on user input
Data flow for the episode page. The transcript store's version 3 JSON is served by a CDN with immutable one-year caching and kept by the service worker and HTTP cache, which also hold the offline copy. The SSR page reads the store at render time and streams HTML with the text and segment times into the DOM in chunks. On first play the word times JSON goes from the cache into the query cache, which builds the TimeIndex once. The TimeIndex feeds Sync and Painters, which also read currentTime from the Player and paint words and lines into the DOM as ranges and classes. A click in the DOM calls seek on the Player.Transcript storev3 JSONCDNimmutable, 1 yearSW + HTTP cacheoffline copySSR pagetext + segment timesTimeIndexbuilt onceQuery cachetranscript by id, vread at rendertimes JSON, on playDOM textchunks, rangesSync + Paintersword, linePlayercurrentTimeHTML streamclick: seek(t)
Two paths for one transcript: the text arrives as HTML with the page, readable and indexable at once; the word times arrive later as JSON, only for people who play. The version number ties them together.

Loading: when to fetch, and how much

Decision When does the transcript load?

Fetch the JSON after playback starts

On first play, fetch the transcript and render the panel.

Pros

  • Zero cost for people who never press play.
  • Audio starts first, with no competing download.

Cons

  • Deaf users, who may never press play, see nothing until they do.
  • Nothing for search engines; no Text Fragments on landing.

Server-render the text with the page, fetch word times later

The episode page HTML contains the transcript with segment start times in data- attributes, so line seek works with no extra fetch.

Word times (the w arrays) come in a separate file fetched on first play, or when the panel scrolls into view, at low priority.

Pros

  • Readable and indexable at first paint.
  • Line-level sync and click-to-seek work before the times file arrives.
  • The heavy part is skipped by readers who never play.

Cons

  • Two representations of one transcript to keep in step: the version number ties them together.
  • Larger HTML for long episodes: about 150 to 200 KB of text for 3 hours before compression.

Inline the whole JSON in the page

Serialize the transcript with words into a script tag for the client to read.

Pros

  • One request; everything ready on load.

Cons

  • The text ships twice (HTML and JSON) and the page grows by hundreds of KB, which can delay the Largest Contentful Paint for everyone.

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

ts
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;
};
Props and events of the transcript panel.

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 (h3 under the episode's h2, 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 time element with a datetime duration (PT12M34S) shows '12:34' and gives each line a clear label for the roving-tabindex keyboard model.
  • The language. Set lang on 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 button with aria-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 Mark and MarkText also adapt in forced colors mode.
  • Text size. The panel uses rem units, 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.

json
{
  "@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..."
  }
}
JSON-LD on the episode page (transcript shortened).

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:

MinutesWhat you doWhat to draw or say
0 to 5Scope questionsEpisode length, word or line timings, sources, search, sharing, offline, SEO. Say the 3 hour number out loud.
5 to 10Wireframe and the hard partsThe panel next to the player; name the hard things: clock, highlight cost, scroll fight, length, messy data.
10 to 17Data and formatsNormalize on the server; segments with text and word times; typed array index built once.
17 to 27Sync and highlightFrame loop plus timeupdate, fast path plus binary search, Highlight API with class fallback. Give the cost per change.
27 to 35Follow mode and seekThe follow state machine with its three states; detect user scroll by intent; selection-safe click; roving tabindex.
35 to 42Long episodes, a11y, loadingcontent-visibility over virtualization and why; no live region; SSR text plus lazy times.
42 to 45Wrap upSearch, 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?

Think first

A creator corrects 40 words while thousands of people are listening. What happens to them?

Think first

What would you measure in production to know the panel is healthy?

Think first

Could this work for a live show with a transcript arriving as people speak?

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:transcript files 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 a requestAnimationFrame loop while playing, and keep timeupdate, seeked and ratechange as 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: auto on 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next