liten

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.

Terminal
bun add motion

Copy the component into components/ui/transcript-stream.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
textstring-The passage to transcribe.
cpsnumber17Characters per second - the write-head speed.
caretbooleantrueShow the write-head caret.
loopbooleanfalseRestart a beat after it finishes.
classNamestring-Forwarded to the text.
On this page0%