## Streaming Text — Async Token by token with a caret. Docs: https://www.interior.dev/docs/streaming-text Reference: https://www.interior.dev/reference/streaming-text License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/streaming-text.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { StreamingText, useStreamingText } from "@/components/interior/streaming-text"; export function AssistantReply({ reply }: { reply: string }) { const [settled, setSettled] = useState(false); return (
setSettled(true)} />
); } export function BareStream({ reply }: { reply: string }) { const { visible, status, skip } = useStreamingText({ text: reply, tokensPerSecond: 24 }); return (

{visible} {status === "done" ? null : "▏"}

); } ``` ### Props - `text` (`string`) The full string to stream. Changing it restarts the stream from the first token. - `tokensPerSecond` (`number`) — default: `18` Reveal rate in words per second. Time is accumulated, not counted in frames, so the rate holds on a slow display. - `autoStart` (`boolean`) — default: `true` When false the component sits at zero tokens with a blinking caret until start() is called on the hook. - `showSkip` (`boolean`) — default: `true` Renders the keyboard-reachable skip control. Its row is present in every state, so hiding it is a layout decision, not a runtime one. - `label` (`string`) — default: `"Streamed response"` Accessible name for the group and the basis of the skip button's label. - `onDone` (`() => void`) Fires once when the last token lands, including when reduced motion skips the stream entirely. - `className` (`string`) — default: `""` Appended last on the root, which carries the type styles, so size, colour and measure are all overridable. ### Behavior notes - The finished paragraph is laid out on the first frame and unrevealed words are held at opacity zero, so a growing answer never pushes the actions beneath it down the page. - The caret is drawn inside a collapsed inline box with no width, so advancing it through the sentence cannot nudge a word onto the next line. - Screen readers receive the completed text once from a polite status region and the visible token layer is aria-hidden, rather than sixty interruptions as words arrive. - Tokens advance on accumulated elapsed time with the per-frame delta clamped, so a tab returning from the background does not dump the remainder of the answer in a single frame. - Under prefers-reduced-motion the whole answer is present immediately and reports done; the text is never withheld behind an effect someone asked not to see. - The reveal is stepped per token rather than per frame, and every animation frame is cancelled when the status changes or the component unmounts. ### Source (`components/interior/streaming-text.tsx`) ```tsx "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CHARS_PER_TOKEN = 4; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8, } as const; const MAX_FRAME_DELTA = 64; export type StreamingTextStatus = "idle" | "streaming" | "paused" | "done"; export type StreamingToken = { word: string; gap: string }; function tokenize(text: string): StreamingToken[] { const tokens: StreamingToken[] = []; for (const part of text.split(/(\s+)/)) { if (!part) continue; if (part.trim() === "") { const last = tokens[tokens.length - 1]; if (last) last.gap += part; else tokens.push({ word: "", gap: part }); continue; } tokens.push({ word: part, gap: "" }); } return tokens; } export type UseStreamingTextOptions = { text: string; tokensPerSecond?: number; autoStart?: boolean; onDone?: () => void; }; export function useStreamingText({ text, tokensPerSecond = 18, autoStart = true, onDone, }: UseStreamingTextOptions) { const reduced = useReducedMotion(); const tokens = useMemo(() => tokenize(text), [text]); const total = text.length; const [index, setIndex] = useState(0); const [status, setStatus] = useState( autoStart ? "streaming" : "idle", ); const cursor = useRef(0); const finished = useRef(onDone); useEffect(() => { finished.current = onDone; }, [onDone]); const start = useCallback(() => { setStatus((s) => (s === "done" ? s : "streaming")); }, []); const pause = useCallback(() => { setStatus((s) => (s === "streaming" ? "paused" : s)); }, []); const skip = useCallback(() => { cursor.current = total; setIndex(total); setStatus("done"); }, [total]); const reset = useCallback(() => { cursor.current = 0; setIndex(0); setStatus(autoStart ? "streaming" : "idle"); }, [autoStart]); useEffect(() => { cursor.current = 0; setIndex(0); setStatus(autoStart ? "streaming" : "idle"); }, [text, autoStart]); useEffect(() => { if (status !== "streaming") return; if (reduced || cursor.current >= total) { cursor.current = total; setIndex(total); setStatus("done"); return; } const interval = 1000 / Math.max(1, tokensPerSecond * CHARS_PER_TOKEN); let frame = 0; let last = performance.now(); let carry = 0; const tick = (now: number) => { carry += Math.min(now - last, MAX_FRAME_DELTA); last = now; if (carry >= interval) { const advance = Math.floor(carry / interval); carry -= advance * interval; const next = Math.min(total, cursor.current + advance); cursor.current = next; setIndex(next); if (next >= total) { setStatus("done"); return; } } frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [status, total, tokensPerSecond, reduced]); useEffect(() => { if (status === "done") finished.current?.(); }, [status]); useEffect(() => { if (!reduced) return; cursor.current = total; setIndex(total); setStatus("done"); }, [reduced, total]); const visible = useMemo(() => text.slice(0, index), [text, index]); return { tokens, index, total, status, visible, start, pause, skip, reset, }; } export type StreamingTextProps = { text: string; tokensPerSecond?: number; autoStart?: boolean; showSkip?: boolean; label?: string; onDone?: () => void; className?: string; }; export function StreamingText({ text, tokensPerSecond = 18, autoStart = true, showSkip = true, label = "Streamed response", onDone, className = "", }: StreamingTextProps) { const { visible, status, skip, start, reset } = useStreamingText({ text, tokensPerSecond, autoStart, onDone, }); const reduced = useReducedMotion(); const done = status === "done"; const blink = !reduced && (status === "idle" || status === "paused"); const caret = ( ); return (

{text} {visible} {caret}

{done ? text : ""} {showSkip ? (
) : null}
); } ```