Transcript Stream
Text that settles onto the page as if speech were being transcribed live - a write-head glides the line and each glyph resolves out of a soft blur.
The write-head glides steadily along the line, character by character, never a per-word hop. Because letters reveal faster than they finish fading, the newest few are always mid-resolve: a soft-focus tail trailing the caret, the way a streaming transcript firms up. The whole passage reads once to screen readers. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/transcript-stream.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_SOFT = [0.25, 0.1, 0.25, 1] as const;function charDelay(ch: string, msPerChar: number) { if (/[.!?]/.test(ch)) return msPerChar + 260; if (/[,;:]/.test(ch)) return msPerChar + 130; return msPerChar;}export type TranscriptStreamProps = { text: string; cps?: number; caret?: boolean; loop?: boolean; className?: string;};export function TranscriptStream({ text, cps = 17, caret = true, loop = false, className,}: TranscriptStreamProps) { const reduce = useReducedMotion(); const chars = React.useMemo(() => Array.from(text), [text]); const n = chars.length; const msPerChar = Math.max(20, 1000 / Math.max(1, cps)); const ref = React.useRef<HTMLSpanElement>(null); const [inView, setInView] = React.useState(false); const [revealed, setRevealed] = React.useState(0); const [done, setDone] = React.useState(false); const [cycle, setCycle] = React.useState(0); React.useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver( ([entry]) => setInView(entry.isIntersecting), { threshold: 0.35 }, ); io.observe(el); return () => io.disconnect(); }, []); React.useEffect(() => { if (!inView) return; if (reduce || n === 0) { setRevealed(n); setDone(true); return; } setDone(false); setRevealed(0); const timers: ReturnType<typeof setTimeout>[] = []; let i = 0; const step = () => { i += 1; setRevealed(i); if (i >= n) { timers.push(setTimeout(() => setDone(true), 420)); if (loop) { timers.push(setTimeout(() => setCycle((c) => c + 1), 3000)); } return; } timers.push(setTimeout(step, charDelay(chars[i - 1], msPerChar))); }; timers.push(setTimeout(step, 380)); return () => timers.forEach(clearTimeout); }, [inView, reduce, n, loop, cycle, chars, msPerChar]); const running = inView && !done && !reduce; const caretNode = caret ? ( <motion.span aria-hidden className="inline-block h-[1.05em] w-[0.075em] translate-y-[0.16em] rounded-full bg-current align-baseline opacity-70" animate={{ opacity: [0.7, 0.1, 0.7] }} transition={{ duration: 1.05, repeat: Infinity, ease: 'easeInOut' }} /> ) : null; return ( <span ref={ref} aria-label={text} className={className}> <span aria-hidden className="whitespace-pre-wrap"> {chars.map((ch, i) => { const hidden = !reduce && !done && i >= revealed; return ( <React.Fragment key={i}> {running && i === revealed ? caretNode : null} <motion.span className="inline" initial={false} animate={{ opacity: hidden ? 0 : 1, filter: `blur(${hidden ? 4 : 0}px)`, }} transition={ hidden ? { duration: 0 } : { opacity: { duration: 0.42, ease: EASE_SOFT }, filter: { duration: 0.6, ease: EASE_SOFT }, } } > {ch} </motion.span> </React.Fragment> ); })} {running && revealed >= n ? caretNode : null} </span> </span> );}Usage
import { TranscriptStream } from '@/components/ui/transcript-stream';
export default function Example() {
return (
<p className="text-2xl font-medium">
<TranscriptStream text="It just arrives, the way a good transcript should." />
</p>
);
}Examples
Set loop to restart the stream a beat after it finishes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | The passage to transcribe. |
cps | number | 17 | Characters per second - the write-head speed. |
caret | boolean | true | Show the write-head caret. |
loop | boolean | false | Restart a beat after it finishes. |
className | string | - | Forwarded to the text. |