A caption band for a live call or video: what people are saying, as they say it. Each line is the speaker's first name in a light tint of the brand colour, then their words. Words that may still change are muted and turn white when final, in place, without the line moving.
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/live-captions#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/live-captionsIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/live-captions#v1.3.10 ourostack/ouro-ui/live-captions-tests#v1.3.10import { LiveCaptions } from "@/components/ouro/live-captions/live-captions";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<LiveCaptions label="Captions" hideLabel lines={[{ id: "1", speaker: "Mara Lind", text: "Shall we start?", final: true }]} />
<LiveCaptions label="Captions" lines={lines} size="lg" announce={readAloud} />
<LiveCaptions label="Captions" lines={[]} unavailable="Captions aren't available in this meeting" />Usage
The band is a solid, translucent black fill with rounded corners and no blur, so it reads over any picture; put it in its own space under the pictures, never over a face. It is always maxLines lines tall, two by default, so the layout never jumps as captions come and go; the newest words sit on the last line, and older lines slide up and off the top (with reduced motion they move at once). Resized, or given a new size or maxLines, the lines take their new places without a slide. Lines wholly over the top edge are hidden from screen readers too. Before anyone speaks it shows a quiet placeholder line, and when the meeting doesn't offer captions it shows why, from unavailable, in place of the lines.
It is a region named by its label. With announce, it reads each line aloud once, when it becomes final, through one polite live region, never a line's interim words; it says at most one announcement every 2 seconds and gathers the lines that finish in between into the next, so a fast talker doesn't flood a screen reader. Lines that were already final when announcing began are not read. announce is off by default, since captions read aloud would talk over the call. Hidden (under a hidden <Activity>, say) and shown again, it reads only the lines that become final from then on.
Its agent side, liveCaptionsAx in live-captions-styles.ts, declares one read, captions (the speaker, the words, and when they were said, which comes from the app's caption feed), which an agent may read only while the person has asked it to follow the meeting (consent listening), and only through a filtered watch, never every word pushed. It declares no actions: turning captions on and off is the person's, declared once in a calling app's own controls.
Use instead when
- Use
Toasterinstead for a passing notice,Textfor subtitles that don't change, andMessageListfor a transcript the person can scroll back through.
Props
Type: LiveCaptionsProps.
announce- Type:
boolean - Default:
false - Reads final lines aloud to screen readers. Off by default, because captions read aloud talk over the call: turn it on when the person asks for captions read aloud.
className- Type:
string - Layout only: width, margins, placement (
max-w-3xl,mx-auto). Appearance classes warn in development; usesize. hideLabel- Type:
boolean - Hides the label visually; screen readers still use it.
id- Type:
string - The band's id, for
aria-describedbyoraria-controlselsewhere on the page. label(required)- Type:
string - What the band is: "Captions". It names the band for screen readers.
lines(required)- Type:
readonly LiveCaptionLine[] - The caption lines, in the order they were said. Feed it as the words come: add a line when someone starts speaking, replace it by its
idas its words grow, and mark itfinalwhen they are settled. The band shows the newest lines and lets older ones go over its top. Pass the recent lines only (a few more thanmaxLines), not the meeting's whole history. maxLines- Type:
number - Default:
2 - How many lines tall the band is, a whole number from 1 to 6. The band is always that tall, empty or full, so nothing around it moves as captions come and go. In a narrow layout a long line wraps and takes more of the band, so give it a line more there.
placeholder- Type:
ReactNode - Default:
"Waiting for someone to speak…" - The quiet line the band shows before anyone has spoken, so the person can see captions are on. Muted, and never read aloud. Translate it here.
ref- Type:
Ref<HTMLDivElement> - Reaches the rendered element.
size- Type:
"sm" | "md" | "lg" - Default:
"md" - The words' size: 14, 18 or 24px. Let the person choose.
unavailable- Type:
ReactNode - Why captions are missing, when the meeting doesn't offer them: "Captions aren't available in this meeting". The band shows it in place of the lines, at the same height, and shows and reads no lines while it is set.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
captions: Not a prop. The lines arelines:<LiveCaptions lines={lines}>.readAloud: Not a prop. Ouro UI usesannounce:<LiveCaptions announce>.status: Not a prop. Before anyone speaks the band showsplaceholder; when the meeting doesn't offer captions,unavailable.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2).
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Types
LiveCaptionLine
One caption line: who is speaking and what they have said so far.
interface LiveCaptionLine {
/** Stays the same while the line firms up, so the app replaces an interim line by its id. */
id: string;
/** Who said it, in full: "Mara Lind". The band shows the first word; an announcement reads it whole. */
speaker: string;
/** What they said so far. */
text: string;
/**
* `true` once the words will not change. Words that are not final show muted and are never announced.
*/
final: boolean;
/** Not a field. A line says it is final with `final`: `{ id, speaker, text, final: true }`. */
isFinal?: { 'Use final: true': never };
}