AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * Shipped with the component through a React 19 hoisted <style> — no Tailwind
 * config edit, and two caption bars on the same page dedupe on the href.
 *
 * `lc-line-in` is the only entrance animation: a CSS animation plays when its
 * element is INSERTED and can never be replayed by a re-render, so a line that
 * merely gains a word does not flash. `lc-live` is the "still listening" dot.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/live-captions.json

Prompt

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.

On This Page