{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-text","type":"registry:ui","title":"Streaming Text","description":"Token by token with a caret.","dependencies":["motion"],"categories":["async"],"docs":"https://www.interior.dev/docs/streaming-text","files":[{"path":"registry/interior/streaming-text.tsx","type":"registry:ui","target":"components/interior/streaming-text.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CHARS_PER_TOKEN = 4;\n\nconst CROSSFADE = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 34,\n  mass: 0.8,\n} as const;\n\nconst MAX_FRAME_DELTA = 64;\n\nexport type StreamingTextStatus = \"idle\" | \"streaming\" | \"paused\" | \"done\";\n\nexport type StreamingToken = { word: string; gap: string };\n\nfunction tokenize(text: string): StreamingToken[] {\n  const tokens: StreamingToken[] = [];\n  for (const part of text.split(/(\\s+)/)) {\n    if (!part) continue;\n    if (part.trim() === \"\") {\n      const last = tokens[tokens.length - 1];\n      if (last) last.gap += part;\n      else tokens.push({ word: \"\", gap: part });\n      continue;\n    }\n    tokens.push({ word: part, gap: \"\" });\n  }\n  return tokens;\n}\n\nexport type UseStreamingTextOptions = {\n  text: string;\n  tokensPerSecond?: number;\n  autoStart?: boolean;\n  onDone?: () => void;\n};\n\nexport function useStreamingText({\n  text,\n  tokensPerSecond = 18,\n  autoStart = true,\n  onDone,\n}: UseStreamingTextOptions) {\n  const reduced = useReducedMotion();\n  const tokens = useMemo(() => tokenize(text), [text]);\n  const total = text.length;\n\n  const [index, setIndex] = useState(0);\n  const [status, setStatus] = useState<StreamingTextStatus>(\n    autoStart ? \"streaming\" : \"idle\",\n  );\n\n  const cursor = useRef(0);\n  const finished = useRef(onDone);\n\n  useEffect(() => {\n    finished.current = onDone;\n  }, [onDone]);\n\n  const start = useCallback(() => {\n    setStatus((s) => (s === \"done\" ? s : \"streaming\"));\n  }, []);\n\n  const pause = useCallback(() => {\n    setStatus((s) => (s === \"streaming\" ? \"paused\" : s));\n  }, []);\n\n  const skip = useCallback(() => {\n    cursor.current = total;\n    setIndex(total);\n    setStatus(\"done\");\n  }, [total]);\n\n  const reset = useCallback(() => {\n    cursor.current = 0;\n    setIndex(0);\n    setStatus(autoStart ? \"streaming\" : \"idle\");\n  }, [autoStart]);\n\n  useEffect(() => {\n    cursor.current = 0;\n    setIndex(0);\n    setStatus(autoStart ? \"streaming\" : \"idle\");\n  }, [text, autoStart]);\n\n  useEffect(() => {\n    if (status !== \"streaming\") return;\n    if (reduced || cursor.current >= total) {\n      cursor.current = total;\n      setIndex(total);\n      setStatus(\"done\");\n      return;\n    }\n\n    const interval = 1000 / Math.max(1, tokensPerSecond * CHARS_PER_TOKEN);\n    let frame = 0;\n    let last = performance.now();\n    let carry = 0;\n\n    const tick = (now: number) => {\n      carry += Math.min(now - last, MAX_FRAME_DELTA);\n      last = now;\n\n      if (carry >= interval) {\n        const advance = Math.floor(carry / interval);\n        carry -= advance * interval;\n        const next = Math.min(total, cursor.current + advance);\n        cursor.current = next;\n        setIndex(next);\n        if (next >= total) {\n          setStatus(\"done\");\n          return;\n        }\n      }\n\n      frame = requestAnimationFrame(tick);\n    };\n\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [status, total, tokensPerSecond, reduced]);\n\n  useEffect(() => {\n    if (status === \"done\") finished.current?.();\n  }, [status]);\n\n  useEffect(() => {\n    if (!reduced) return;\n    cursor.current = total;\n    setIndex(total);\n    setStatus(\"done\");\n  }, [reduced, total]);\n\n  const visible = useMemo(() => text.slice(0, index), [text, index]);\n\n  return {\n    tokens,\n    index,\n    total,\n    status,\n    visible,\n    start,\n    pause,\n    skip,\n    reset,\n  };\n}\n\nexport type StreamingTextProps = {\n  text: string;\n  tokensPerSecond?: number;\n  autoStart?: boolean;\n  showSkip?: boolean;\n  label?: string;\n  onDone?: () => void;\n  className?: string;\n};\n\nexport function StreamingText({\n  text,\n  tokensPerSecond = 18,\n  autoStart = true,\n  showSkip = true,\n  label = \"Streamed response\",\n  onDone,\n  className = \"\",\n}: StreamingTextProps) {\n  const { visible, status, skip, start, reset } = useStreamingText({\n    text,\n    tokensPerSecond,\n    autoStart,\n    onDone,\n  });\n  const reduced = useReducedMotion();\n\n  const done = status === \"done\";\n  const blink = !reduced && (status === \"idle\" || status === \"paused\");\n\n\n  const caret = (\n    <span\n      aria-hidden\n      className=\"relative inline-block h-[1.1em] w-0 align-[-0.22em]\"\n    >\n      <motion.span\n        className=\"absolute inset-y-0 left-px block w-[2px] bg-stone-800 dark:bg-stone-100\"\n        initial={false}\n        animate={blink ? { opacity: [1, 1, 0, 0] } : { opacity: done ? 0 : 1 }}\n        transition={\n          blink\n            ? {\n                duration: 1.06,\n                times: [0, 0.45, 0.5, 0.95],\n                repeat: Infinity,\n                ease: \"linear\",\n              }\n            : CROSSFADE\n        }\n      />\n    </span>\n  );\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      aria-busy={status === \"streaming\"}\n      className={`text-[13.5px] leading-relaxed text-stone-700 dark:text-stone-200 ${className}`}\n    >\n      <p aria-hidden className=\"relative whitespace-pre-line\">\n        <span className=\"invisible\">{text}</span>\n\n        <span className=\"absolute inset-0 whitespace-pre-line\">\n          {visible}\n          {caret}\n        </span>\n      </p>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {done ? text : \"\"}\n      </span>\n\n      {showSkip ? (\n        <div className=\"mt-2.5 flex justify-end\">\n          <button\n            type=\"button\"\n            onClick={\n              done\n                ? () => {\n                    reset();\n                    start();\n                  }\n                : skip\n            }\n            aria-label={\n              done\n                ? `Replay ${label}`\n                : \"Skip to the end\"\n            }\n            className=\"inline-grid h-7 place-items-center rounded-[6px] border border-stone-200 px-2.5 text-[11.5px] font-medium text-stone-500 transition-colors duration-150 hover:text-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/[0.16] dark:text-stone-400 dark:hover:text-stone-200 dark:focus-visible:ring-stone-500\"\n          >\n            <motion.span\n              aria-hidden\n              className=\"col-start-1 row-start-1\"\n              initial={false}\n              animate={{ opacity: done ? 0 : 1 }}\n              transition={reduced ? { duration: 0 } : CROSSFADE}\n            >\n              Skip\n            </motion.span>\n            <motion.span\n              aria-hidden\n              className=\"col-start-1 row-start-1\"\n              initial={false}\n              animate={{ opacity: done ? 1 : 0 }}\n              transition={reduced ? { duration: 0 } : CROSSFADE}\n            >\n              Replay\n            </motion.span>\n          </button>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"}]}