Transcript
An ordered list of what was said, with speaker, offset and an optional confidence band — auto-following the tail by position rather than by guessing who scrolled.
conversationdata
Install
pnpm dlx shadcn@4.23.0 add @ansora/transcriptExamples
Usage
There is no virtualization in v1, and this is the ceiling rather than silence about it.
Budget 5 elements and 8 DOM nodes for a bare turn — the <li>, the meta <p> holding a speaker <span> and a <time>, and the text <p> — and 8 elements and 13 DOM nodes for a turn with a role, a confidence pill and onSelect, which is the shape the scribe's screen actually renders (the role <span>, the pill <span>, and the overlay <button>). The cited row adds 1 element and 2 DOM nodes on top of that for activeLabel — the sr-only <span> and the text inside it. Those figures are MEASURED, not counted by hand: apps/docs/transcript-runtime.test.tsx renders every shape named here and fails if this paragraph disagrees with what came out. The version before it was hand-counted and wrong.
- Up to ~800 turns: comfortable. Appends stay inside one frame; initial mount is 60–120 ms.
- 800–2 000 turns: usable but degraded. Initial mount is visible, and any full re-render of the parent (a density change, a theme toggle, an unstable prop) stalls perceptibly.
- Above ~2 000 turns: do not ship it. Render fewer turns, or wait for v2.
Those three figures are an engineering estimate from the node budget above, not a measurement: nothing in this repo profiles a render, and pnpm shots photographs a first paint without ever counting a dropped frame.
Before you reach for a ceiling, reduce the turn count. How many turns a visit produces is a property of your ASR's segmentation, not of the visit: diarised speaker turns give 400–800 for forty minutes, raw VAD segments give 1 200–2 400. Merging consecutive segments from the same speaker before you pass them in is a reduce in your data layer, cuts the count 3–5x, and reads better. That is the intended fix, not a workaround — and it is also the accessibility fix, because it stops the offset and the speaker name being spoken on every line.
Passing onSelect stretches a <button> over every row, which adds one tab stop per turn. With onSelect, halve every number above. The button is an OVERLAY rather than a wrapper around the row's two <p>s — a wrapper is an invalid content model and gives the button an accessible name four hundred characters long — so it is named by aria-labelledby from the speaker and the offset instead.
activeId requires activeLabel, the invisible Czech name of what the highlight means ("Citovaná promluva"). The cited row is carried three ways: the background tint, an inline-start bar in foreground drawn transparently on every row so switching it on shifts nothing, and that sr-only name. The tint alone is a surface-on-surface pair at 1.08:1 — not a WCAG number — and aria-current on a non-focusable <li> is announced patchily, so neither a low-vision reader nor a screen-reader user arriving by Tab received the state at all.
Lighthouse's DOM-size audit warns above 800 elements per document; this component crosses that at roughly 100 turns (160 for bare turns). That is derived from the measurement above by the same test, because the number stated here used to contradict the budget two paragraphs up by about 1.75x. It is an advisory about whole pages, not about one contained list — it is not the limit stated here.
What v2 must not break. Follow is computed from scrollTop / scrollHeight / clientHeight and nothing else, so a virtualizer keeps it correct; the component owns its own scroll element; every scroll goes through one function; no prop implies every turn is in the DOM; the row is already a memoised component keyed by turn.id. And when v2 arrives: use the WINDOWING form (padding on the <ol> plus a start offset), not the standard absolute-positioning recipe — that one destroys <li> being a flow child of <ol> and with it the "položka 3 z 412" announcement. Losing list semantics to gain a frame rate is the wrong trade.
Do not pass scroll to a SectionCard you wrap this in: it renders its own focusable, named scroll region, and nesting two is the same trap @ansora/data-table documents.
The four states, and which of them are yours. web-ui-conventions asks every screen for empty / loading / error / content, and this component owns exactly two of them: content (turns) and empty (turns={[]} renders empty through @ansora/empty-state). Loading is yours and there is deliberately no loading prop — a live transcript has no loading state worth the name (it starts empty and grows), and a REPLAYED one is loaded by your data layer, which knows whether to show a skeleton, the previous visit or nothing at all. A degraded or dropped ASR is not this component's error either — it is @ansora/session-bar's notice, beside the state that says whether capture is still running, which is the fact the user actually needs. A transcript that renders its own red box would say "the transcript failed" when what failed was the capture.
Announcing is off unless you ask for it. live says the transcript is still growing; announce="polite" is the separate, throttled, sr-only region that speaks only the newest turn and DROPS the rest. Nothing in this repo can observe an announcement, so treat both the six-second interval and the wording as unverified.
It has no height of its own — an ancestor must give it one. Transcript fills its parent (flex min-h-0 flex-col, and the example's h-full). Against a parent with an INDEFINITE height, height: 100% resolves to auto, the list grows instead of scrolling, and scrollHeight equals clientHeight — so follow never engages, missed never counts and the resume button never appears. Nothing throws and nothing looks broken; the feature is simply inert. Give it a definite height: h-96, a grid row, or flex-1 inside a h-dvh column. This bites hardest in the placement these docs otherwise recommend — AppShell's content area is min-h-svh, which is a MINIMUM and therefore indefinite.
"use client"
import { Transcript, type TranscriptTurn } from "@/components/ansora/transcript"
const promluvy: TranscriptTurn[] = zaznam.promluvy
const [sleduji, setSleduji] = React.useState(true)
// The wrapper is not decoration — see the paragraph above.
<div className="h-96">
<Transcript
turns={promluvy}
label="Přepis vizity"
empty={{ title: "Zatím nic nezaznělo", description: "Až začne rozhovor, objeví se přepis tady." }}
live
announce="off"
follow={{
on: sleduji,
onChange: setSleduji,
resumeLabel: (n) => `Sledovat nejnovější (${n})`,
}}
className="h-full"
/>
</div>
Files
registry/ansora/components/transcript.tsx→@components/ansora/transcript.tsx
Dependencies
@ansora/base@ansora/status-pill@ansora/empty-statehttps://ui.shadcn.com/r/styles/base-nova/button.json
Výběr termínu
Nabídnuté termíny seskupené po dnech, jedna volba, ovladatelné jen klávesnicí — sémantika radiogroup.
Ansora value change
One 'from → to' pair: what a value was and what it is now, legible without relying on colour. The caller names both sides, which is also what a screen reader announces.