{ "name": "response-stream", "type": "registry:ui", "registryDependencies": [], "dependencies": [], "devDependencies": [], "tailwind": {}, "cssVars": { "light": {}, "dark": {} }, "description": "A component to simulate streaming text on the client side, perfect for fake responses, or any controlled progressive text display.", "files": [ { "path": "response-stream.tsx", "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport type Mode = \"typewriter\" | \"fade\"\n\nexport type UseTextStreamOptions = {\n textStream: string | AsyncIterable\n speed?: number\n mode?: Mode\n onComplete?: () => void\n fadeDuration?: number\n segmentDelay?: number\n characterChunkSize?: number\n onError?: (error: unknown) => void\n}\n\nexport type UseTextStreamResult = {\n displayedText: string\n isComplete: boolean\n segments: { text: string; index: number }[]\n getFadeDuration: () => number\n getSegmentDelay: () => number\n reset: () => void\n startStreaming: () => void\n pause: () => void\n resume: () => void\n}\n\nfunction useTextStream({\n textStream,\n speed = 20,\n mode = \"typewriter\",\n onComplete,\n fadeDuration,\n segmentDelay,\n characterChunkSize,\n onError,\n}: UseTextStreamOptions): UseTextStreamResult {\n const [displayedText, setDisplayedText] = useState(\"\")\n const [isComplete, setIsComplete] = useState(false)\n const [segments, setSegments] = useState<{ text: string; index: number }[]>(\n []\n )\n\n const speedRef = useRef(speed)\n const modeRef = useRef(mode)\n const currentIndexRef = useRef(0)\n const animationRef = useRef(null)\n const fadeDurationRef = useRef(fadeDuration)\n const segmentDelayRef = useRef(segmentDelay)\n const characterChunkSizeRef = useRef(characterChunkSize)\n const streamRef = useRef(null)\n const completedRef = useRef(false)\n const onCompleteRef = useRef(onComplete)\n\n useEffect(() => {\n speedRef.current = speed\n modeRef.current = mode\n fadeDurationRef.current = fadeDuration\n segmentDelayRef.current = segmentDelay\n characterChunkSizeRef.current = characterChunkSize\n }, [speed, mode, fadeDuration, segmentDelay, characterChunkSize])\n\n useEffect(() => {\n onCompleteRef.current = onComplete\n }, [onComplete])\n\n const getChunkSize = useCallback(() => {\n if (typeof characterChunkSizeRef.current === \"number\") {\n return Math.max(1, characterChunkSizeRef.current)\n }\n\n const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n\n if (modeRef.current === \"typewriter\") {\n if (normalizedSpeed < 25) return 1\n return Math.max(1, Math.round((normalizedSpeed - 25) / 10))\n } else if (modeRef.current === \"fade\") {\n return 1\n }\n\n return 1\n }, [])\n\n const getProcessingDelay = useCallback(() => {\n if (typeof segmentDelayRef.current === \"number\") {\n return Math.max(0, segmentDelayRef.current)\n }\n\n const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed)))\n }, [])\n\n const getFadeDuration = useCallback(() => {\n if (typeof fadeDurationRef.current === \"number\")\n return Math.max(10, fadeDurationRef.current)\n\n const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n return Math.round(1000 / Math.sqrt(normalizedSpeed))\n }, [])\n\n const getSegmentDelay = useCallback(() => {\n if (typeof segmentDelayRef.current === \"number\")\n return Math.max(0, segmentDelayRef.current)\n\n const normalizedSpeed = Math.min(100, Math.max(1, speedRef.current))\n return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed)))\n }, [])\n\n const updateSegments = useCallback((text: string) => {\n if (modeRef.current === \"fade\") {\n try {\n const segmenter = new Intl.Segmenter(navigator.language, {\n granularity: \"word\",\n })\n const segmentIterator = segmenter.segment(text)\n const newSegments = Array.from(segmentIterator).map(\n (segment, index) => ({\n text: segment.segment,\n index,\n })\n )\n setSegments(newSegments)\n } catch (error) {\n const newSegments = text\n .split(/(\\s+)/)\n .filter(Boolean)\n .map((word, index) => ({\n text: word,\n index,\n }))\n setSegments(newSegments)\n onError?.(error)\n }\n }\n }, [])\n\n const markComplete = useCallback(() => {\n if (!completedRef.current) {\n completedRef.current = true\n setIsComplete(true)\n onCompleteRef.current?.()\n }\n }, [])\n\n const reset = useCallback(() => {\n currentIndexRef.current = 0\n setDisplayedText(\"\")\n setSegments([])\n setIsComplete(false)\n completedRef.current = false\n\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n animationRef.current = null\n }\n }, [])\n\n const processStringTypewriter = useCallback(\n (text: string) => {\n let lastFrameTime = 0\n\n const streamContent = (timestamp: number) => {\n const delay = getProcessingDelay()\n if (delay > 0 && timestamp - lastFrameTime < delay) {\n animationRef.current = requestAnimationFrame(streamContent)\n return\n }\n lastFrameTime = timestamp\n\n if (currentIndexRef.current >= text.length) {\n markComplete()\n return\n }\n\n const chunkSize = getChunkSize()\n const endIndex = Math.min(\n currentIndexRef.current + chunkSize,\n text.length\n )\n const newDisplayedText = text.slice(0, endIndex)\n\n setDisplayedText(newDisplayedText)\n if (modeRef.current === \"fade\") {\n updateSegments(newDisplayedText)\n }\n\n currentIndexRef.current = endIndex\n\n if (endIndex < text.length) {\n animationRef.current = requestAnimationFrame(streamContent)\n } else {\n markComplete()\n }\n }\n\n animationRef.current = requestAnimationFrame(streamContent)\n },\n [getProcessingDelay, getChunkSize, updateSegments, markComplete]\n )\n\n const processAsyncIterable = useCallback(\n async (stream: AsyncIterable) => {\n const controller = new AbortController()\n streamRef.current = controller\n\n let displayed = \"\"\n\n try {\n for await (const chunk of stream) {\n if (controller.signal.aborted) return\n\n displayed += chunk\n setDisplayedText(displayed)\n updateSegments(displayed)\n }\n\n markComplete()\n } catch (error) {\n console.error(\"Error processing text stream:\", error)\n markComplete()\n onError?.(error)\n }\n },\n [updateSegments, markComplete, onError]\n )\n\n const startStreaming = useCallback(() => {\n reset()\n\n if (typeof textStream === \"string\") {\n processStringTypewriter(textStream)\n } else if (textStream) {\n processAsyncIterable(textStream)\n }\n }, [textStream, reset, processStringTypewriter, processAsyncIterable])\n\n const pause = useCallback(() => {\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n animationRef.current = null\n }\n }, [])\n\n const resume = useCallback(() => {\n if (typeof textStream === \"string\" && !isComplete) {\n processStringTypewriter(textStream)\n }\n }, [textStream, isComplete, processStringTypewriter])\n\n useEffect(() => {\n startStreaming()\n\n return () => {\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n }\n if (streamRef.current) {\n streamRef.current.abort()\n }\n }\n }, [textStream, startStreaming])\n\n return {\n displayedText,\n isComplete,\n segments,\n getFadeDuration,\n getSegmentDelay,\n reset,\n startStreaming,\n pause,\n resume,\n }\n}\n\nexport type ResponseStreamProps = {\n textStream: string | AsyncIterable\n mode?: Mode\n speed?: number // 1-100, where 1 is slowest and 100 is fastest\n className?: string\n onComplete?: () => void\n as?: keyof React.JSX.IntrinsicElements // Element type to render\n fadeDuration?: number // Custom fade duration in ms (overrides speed)\n segmentDelay?: number // Custom delay between segments in ms (overrides speed)\n characterChunkSize?: number // Custom characters per frame for typewriter mode (overrides speed)\n}\n\nfunction ResponseStream({\n textStream,\n mode = \"typewriter\",\n speed = 20,\n className = \"\",\n onComplete,\n as = \"div\",\n fadeDuration,\n segmentDelay,\n characterChunkSize,\n}: ResponseStreamProps) {\n const animationEndRef = useRef<(() => void) | null>(null)\n\n const {\n displayedText,\n isComplete,\n segments,\n getFadeDuration,\n getSegmentDelay,\n } = useTextStream({\n textStream,\n speed,\n mode,\n onComplete,\n fadeDuration,\n segmentDelay,\n characterChunkSize,\n })\n\n useEffect(() => {\n animationEndRef.current = onComplete ?? null\n }, [onComplete])\n\n const handleLastSegmentAnimationEnd = useCallback(() => {\n if (animationEndRef.current && isComplete) {\n animationEndRef.current()\n }\n }, [isComplete])\n\n // fadeStyle is the style for the fade animation\n const fadeStyle = `\n @keyframes fadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n }\n \n .fade-segment {\n display: inline-block;\n opacity: 0;\n animation: fadeIn ${getFadeDuration()}ms ease-out forwards;\n }\n\n .fade-segment-space {\n white-space: pre;\n }\n `\n\n const renderContent = () => {\n switch (mode) {\n case \"typewriter\":\n return <>{displayedText}\n\n case \"fade\":\n return (\n <>\n \n
\n {segments.map((segment, idx) => {\n const isWhitespace = /^\\s+$/.test(segment.text)\n const isLastSegment = idx === segments.length - 1\n\n return (\n \n {segment.text}\n \n )\n })}\n
\n \n )\n\n default:\n return <>{displayedText}\n }\n }\n\n const Container = as as keyof React.JSX.IntrinsicElements\n\n return {renderContent()}\n}\n\nexport { useTextStream, ResponseStream }\n", "type": "registry:ui" } ] }