## 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 (
{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{text} {visible} {caret}
{done ? text : ""} {showSkip ? (