Live Captions
A broadcast-style caption bar for a live recogniser feed — interim words render muted and solidify when finalised, a rolling two-line window follows the speech, speaker chips carry the turn, and the panel hides itself after silence.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/live-captions.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LiveCaptions" component: the caption bar
that sits over a live call while a speech recogniser is still deciding what was
said. No animation library — CSS keyframes only.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children; the rest of the props spread onto the root, so a caller can
override aria-label.
- segments: Segment[] where Segment = { text: string; final?: boolean;
speaker?: string }. Oldest first, append-only; only the LAST entry may be
rewritten, which is exactly what an interim hypothesis is.
- variant?: "overlay" | "inline" (default "overlay"). overlay pins the bar to
the bottom of the nearest positioned ancestor and is click-through; inline is
an ordinary block in the flow.
- maxLines?: number (default 2) — how many caption lines stay on screen.
- maxCharsPerLine?: number (default 56) — the packing budget; it also caps the
panel width so a packed line never wraps a second time.
- speakers?: { id: string; label: string; tone?: 1|2|3|4|5 }[] (default
[{ id: "user", label: "You", tone: 1 },
{ id: "assistant", label: "Assistant", tone: 2 }]).
- showSpeakers?: boolean (default true).
- idleHideAfter?: number in ms (default 0 = never hide).
- placeholder?: ReactNode — shown while nothing has been recognised yet.
- announce?: "final" | "off" (default "final").
- onVisibilityChange?: (visible: boolean) => void.
Behavior — interim vs final (the whole point)
- A word inherits `final` from its segment. Final words render at text-
foreground, interim words at text-muted-foreground, and the flip is a colour
transition (~200ms), so a phrase visibly SOLIDIFIES instead of blinking.
- The transition only happens if the DOM node survives the frame: key every
word "<segmentIndex>:<wordIndex>" so a word already on screen keeps its node
when the recogniser sends a longer hypothesis. Never key by text.
- Never mutate or re-order the incoming array; everything is derived per render.
Behavior — line packing (why the bar keeps its height)
- Flatten segments to words, then pack greedily left to right into lines. Open a
new line on: (a) a speaker change, (b) a finalised phrase that ended in
terminal punctuation [.!?…。!?] plus optional closing quotes/brackets, or
(c) the character budget being exceeded. A single word longer than the budget
gets a line of its own rather than being cut.
- Packing at word level (not segment level) is what stops one long interim
hypothesis from blowing the window: a 200-character guess becomes 4 lines of
which only the last `maxLines` are shown.
- Render the LAST `maxLines` packed lines. The oldest visible line renders at
opacity-60 — readable, but visibly on its way out.
- Bound the work: packing is greedy, so it may only start at a boundary the
packer would have produced anyway. Scan backwards from the end until enough
text for maxLines+2 lines has been collected AND the scan lands on a HARD
break (speaker change or finished sentence); that prefix pass is identical to
a full pass. Cap the scan (~240 segments) so a monologue without a single
sentence end still costs O(1) per frame instead of re-packing an hour of
speech every token — and snap that capped start to a stride (~64 segments),
or it slides forward one segment per frame and re-breaks the whole window on
every frame.
Behavior — speakers
- Group the visible lines by speaker run and show the chip only on the line that
LEADS a run — plus always on the top visible line, even mid-sentence: it is
the only place a reader can pick up who is talking after the line above it has
scrolled away.
- An id with no entry in `speakers` falls back to the raw id as its label and to
a tone derived by FNV-1a hash of the id mod 5, so an unconfigured speaker
still keeps one stable colour for the whole session (and the server and the
client agree on it).
- The chip of the newest line shows a pulsing dot while that line still contains
interim words — the "still listening" tell.
Behavior — auto-hide after silence
- Build a `pulse` string from the tail: segment count + last text + last final
flag. Any new or rewritten phrase changes it; nothing else does.
- When `pulse` changes, reset the hidden flag DURING RENDER (the "adjust state
on prop change" pattern), not in an effect: the frame where the bar is still
hidden must never be committed, or the captions arrive one paint late.
- One effect owns one timeout keyed on (idleHideAfter, pulse): it fires once and
hides. Clear it on unmount and on every restart. Never call setState
synchronously in the effect body.
- The clock only starts once something has been said, so a placeholder waiting
for the first word is never hidden. Setting idleHideAfter back to 0 re-reveals
immediately, without another render pass.
- Fire onVisibilityChange on transitions only (compare against a ref), never on
mount, so a host can move its control bar in step.
Behavior — screen readers
- The visible stack churns: words are rewritten, lines are dropped. Reading it
live would be unusable, so mark it aria-hidden and mirror only FINALISED
phrases into an off-screen div role="log" aria-live="polite"
aria-atomic="false", one span per phrase, prefixed with the speaker label when
the turn changes. It is append-only, so a polite log speaks each finished
phrase exactly once; drop the head past ~12 entries (removals are not
announced).
- Derive the log from props — no effect, no timer, nothing to leak.
- announce="off" renders no log and leaves the visible text readable, for hosts
whose call UI already announces the transcript.
- Root is role="region" with aria-label="Live captions"; decorative dots are
aria-hidden by being inside the hidden stack.
Rendering & styling
- Semantic tokens only: text-foreground, text-muted-foreground, bg-card,
bg-background/85 + backdrop-blur-sm for the overlay panel, border, shadow-sm,
and var(--chart-1..5) for speaker accents (chip background is
color-mix(in oklab, var(--chart-N) 16%, transparent)). No hex, no rgb(), no
oklch() literals.
- Overlay: pointer-events-none, absolute inset-x-0 bottom-0, flex column
justify-end so new lines push the stack UP from the bottom edge; the panel is
w-fit and centred with text-center. Inline: left aligned, bg-card, in flow.
- Set the panel's max width from the same budget — min(100%, maxCharsPerLine+2
ch) — so the CSS wrap point and the packing width agree and a packed line can
never wrap twice.
- Keyframes ship in a React 19 hoisted <style href precedence>, so two caption
bars on a page dedupe. A CSS animation plays on INSERTION and cannot be
replayed by a re-render, which is what makes the line entrance a one-shot: a
line that merely gains a word must not flash.
- prefers-reduced-motion: motion-reduce turns off the entrance animation, the
listening pulse, the colour transition and the hide translate — interim still
reads muted, finalised still reads solid, hiding still hides. Nothing but the
motion is removed.
Customization levers
- Window size: maxLines 1 (a single strip), 2 (broadcast default), 3 (a roomier
meeting panel). maxCharsPerLine 30–70 trades line count for line length; it
drives the panel width too, so change one number, not two.
- Interim styling: the muted/solid pair is one cn() branch per word — swap it
for an opacity ramp, an italic tail, or a dotted underline if your recogniser
is noisy and you want the guess to look even weaker.
- Fade of the outgoing line: opacity-60 on index 0; drop it for a flat stack, or
extend it to a per-index ramp for a longer window.
- Overlay chrome: bg-background/85 + backdrop-blur-sm is the "over video" look;
use bg-background alone for opaque broadcast captions, or drop the border and
shadow for a bare text overlay. Remove pointer-events-none if you want
captions to be selectable (they will then eat clicks meant for the player).
- Speaker chips: showSpeakers={false} for a single-voice bar; move the chip to a
fixed left column for a stable transcript grid; or replace the chip with an
avatar and keep the tone as its ring colour.
- Auto-hide: idleHideAfter 1500–4000ms is the usable band; pair it with
onVisibilityChange to lift a control bar when the captions go away.
- Language: terminal punctuation already includes CJK stops; for scripts with no
spaces, replace the word split with an Intl.Segmenter("granularity: word")
pass and keep the rest of the packer unchanged.Concepts
- Interim vs final — a phrase arrives as a guess and is later confirmed; the guess is muted and free to be rewritten, confirmation only changes its colour, and because each word keeps its DOM node the change reads as the text solidifying rather than as a repaint.
- Word-level packing — lines are packed from words, not from segments, so one long hypothesis cannot push the bar past its height; the budget, a change of voice and a finished sentence are the only three things that start a new line.
- Rolling window — only the last few packed lines exist on screen; the oldest one dims first so a reader can tell which line is about to leave, and everything above it is simply gone, not scrolled.
- Anchored partial pass — greedy packing gives different breaks if you start mid-line, so the backward scan stops only at a boundary the packer would have produced anyway (a turn or a sentence end); that keeps per-frame work constant on an hour-long transcript without changing a single line break, and the hard cap that catches an unpunctuated monologue is snapped to a stride so it does not creep forward one segment per frame.
- Idle hide and return — silence is measured from the tail of the feed, not from a clock the host has to drive; the first new word both cancels the hidden state during render and restarts the timer, so the captions are back on the same paint as the word that woke them.
- Quiet caption log — the visible stack is hidden from assistive tech precisely because it churns, and finalised phrases are mirrored into a polite append-only log, so a screen reader hears each phrase once instead of restarting on every hypothesis.
Voice Chat
A realtime voice session panel — speaking orb, word-by-word live captions, mute, mic picker, connection meter, session clock, and an end-of-call summary.
Voice Picker
Pick the TTS voice a reply is spoken in — cards or a combobox, language and accent chips, neutral timbre tags, and a play button that auditions without selecting, with exactly one sample sounding at a time.