Transcription Word Lock
Each spoken word lands dimmed under a dotted rule, then firms up to solid once the recognizer commits.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Transcription Word Lock
*
* Speech landing in two stages: each word arrives tentative — dimmed,
* with a dotted underline — and firms up to solid a beat later, when the
* recognizer stops second-guessing it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Word tone is derived from the inherited text color, so the transcript
* reads correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `text`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TranscriptionWordLockProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What is being transcribed. Split on whitespace — a word is one tick. */
text?: string;
/** Header label while words are still arriving. */
liveLabel?: string;
/** Header label once every word has firmed up. */
finalLabel?: string;
/** Accent for the live marker. */
accent?: string;
/** Fires once the last word locks. */
onComplete?: () => void;
};
type VariantConfig = {
/** ms between words — the perceived speaking rate. */
cadenceMs: number;
/** ms a word stays tentative before it firms up. */
lockLagMs: number;
/** Opacity of a word the recognizer is still unsure about. */
tentativeOpacity: number;
/** How long the firm-up takes. */
lockSeconds: number;
};
// Nothing here moves: words that slide or scale as they firm up would be
// unreadable while they are being read, which is the whole point of a live
// transcript. The only channels are opacity and the underline — the two
// things the eye can absorb without re-fixating.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a change in weight — for a transcript sitting behind other
// content, where certainty matters less than legibility.
subtle: {
cadenceMs: 105,
lockLagMs: 300,
tentativeOpacity: 0.55,
lockSeconds: 0.34,
},
// A clear two-stage read. The all-purpose setting.
default: {
cadenceMs: 92,
lockLagMs: 340,
tentativeOpacity: 0.42,
lockSeconds: 0.26,
},
// A longer tentative tail and a snappier lock, for a captions panel
// where the firming-up is the thing being demonstrated.
playful: {
cadenceMs: 78,
lockLagMs: 420,
tentativeOpacity: 0.32,
lockSeconds: 0.2,
},
};
const SAMPLE_TEXT =
"Let's move the vendor review to Thursday and send the updated invoice before the call so finance can close the month.";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` gives a panel, a border and a tentative underline that
* are correctly toned in either theme. The live marker stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function TranscriptionWordLock({
variant = "default",
text = SAMPLE_TEXT,
liveLabel = "Live",
finalLabel = "Final",
accent = "#7C7CF0",
onComplete,
}: TranscriptionWordLockProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const words = text.trim().split(/\s+/);
const wordCount = words.length;
const [arrived, setArrived] = useState(0);
const [locked, setLocked] = useState(0);
const done = locked >= wordCount;
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
// Two streams at the same cadence, the second offset by the lock lag:
// that offset is what keeps a constant tail of unsure words at the write
// head instead of a backlog that grows without end. The cadence itself
// survives reduced motion — words landing is the data arriving, not
// decoration; only the fades below are dropped.
useEffect(() => {
if (arrived >= wordCount) return;
const id = setTimeout(() => setArrived((n) => n + 1), cfg.cadenceMs);
return () => clearTimeout(id);
}, [arrived, wordCount, cfg.cadenceMs]);
useEffect(() => {
if (locked >= arrived) return;
const id = setTimeout(
() => setLocked((n) => n + 1),
locked === 0 ? cfg.lockLagMs : cfg.cadenceMs
);
return () => clearTimeout(id);
}, [locked, arrived, cfg.cadenceMs, cfg.lockLagMs]);
useEffect(() => {
if (done) onCompleteRef.current?.();
}, [done]);
const lockTransition = reduceMotion
? { duration: 0 }
: { duration: cfg.lockSeconds, ease: "easeOut" as const };
return (
<div
style={{
width: 300,
display: "flex",
flexDirection: "column",
gap: 10,
padding: 14,
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(11)}`,
fontSize: 13.5,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect
x="5.8"
y="1.8"
width="4.4"
height="7.6"
rx="2.2"
stroke="currentColor"
strokeWidth="1.4"
opacity="0.6"
/>
<path
d="M3.4 7.6a4.6 4.6 0 0 0 9.2 0M8 12.2v2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
opacity="0.6"
/>
</svg>
<span style={{ fontSize: 11.5, opacity: 0.55 }}>Transcript</span>
{/* Both header states share one grid cell, so the word that names
the state can never shift the row it sits in. */}
<span
style={{
marginLeft: "auto",
display: "grid",
justifyItems: "end",
whiteSpace: "nowrap",
}}
>
<motion.span
initial={false}
animate={{ opacity: done ? 0 : 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
gridArea: "1 / 1",
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: 11,
fontWeight: 600,
color: accent,
}}
>
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
}}
/>
{liveLabel}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: done ? 0.6 : 0 }}
transition={{ duration: 0.24, ease: "easeOut", delay: done ? 0.1 : 0 }}
style={{ gridArea: "1 / 1", fontSize: 11, fontWeight: 600 }}
>
{finalLabel}
</motion.span>
</span>
</div>
<p
aria-live="polite"
style={{
margin: 0,
minHeight: 68,
lineHeight: 1.66,
wordBreak: "break-word",
}}
>
{words.slice(0, arrived).map((word, index) => {
const isLocked = index < locked;
return (
<span key={index}>
{/* Each word is its own inline-block so the dotted rule can
be positioned under it without leaving the text flow —
the words themselves wrap exactly as plain copy would. */}
<span style={{ position: "relative", display: "inline-block" }}>
<motion.span
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: isLocked ? 1 : cfg.tentativeOpacity }}
transition={lockTransition}
style={{ display: "inline-block" }}
>
{word}
</motion.span>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isLocked ? 0 : 1 }}
transition={lockTransition}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 1,
borderBottom: `1px dotted ${tone(38)}`,
}}
/>
</span>{" "}
</span>
);
})}
</p>
</div>
);
}About this pattern
Live speech recognition revises itself constantly, and a transcript that hides this leaves the reader unsure which parts they can trust. Two states carry it: a tentative word is dim with a dotted rule beneath it, a committed one is solid with the rule gone. The tail of unsure words stays a constant few behind the write head, so the boundary between guess and record is always visible. Nothing moves — words that slid or scaled as they firmed up would be unreadable while they are being read, which is the only thing a transcript is for.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Live meeting transcripts where recent speech is styled as provisional until it settles.
Related patterns
- Voice Waveform ListenBars rise and fall against the room while the microphone is open, so the mic reads as live.
- Streaming Code BlockGenerated code arrives line by line while the panel grows downward to fit it.
- Tool Call BadgeA badge announces the tool an agent is calling, turns while it runs, then settles into the result count.