"use client"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface StreamingTextProps { /** The text so far. Append to it as more arrives; characters already shown never replay. */ text: string; /** Shows a blinking cursor at the end, for while more is still on its way. Default false. */ streaming?: boolean; className?: string; } /** Splits into individual characters, so each one can fade in on its own as it arrives. */ function tokenize(text: string): string[] { return Array.from(text); } /** * Reveals text as it streams in. Feed it a growing string and each new * character fades in fast enough to read as one smooth ribbon, not single * letters popping in. Characters already shown never replay. */ export function StreamingText({ text, streaming = false, className }: StreamingTextProps) { const reduce = useReducedMotion(); const tokens = useMemo(() => tokenize(text), [text]); // Tokens shown as of the last render. A shorter text (a new message) counts as fully settled. const shown = useRef(0); const settled = Math.min(tokens.length, shown.current); useEffect(() => { shown.current = tokens.length; }, [tokens.length]); return (
{tokens.map((token, index) => {
const isGlyph = /\S/.test(token);
if (!isGlyph || index < settled || reduce) {
// biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown.
return {token};
}
return (