{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "streaming-text.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Fragment,\n  forwardRef,\n  type HTMLAttributes,\n  memo,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface StreamingTextProps extends HTMLAttributes<HTMLSpanElement> {\n  /** The full text to stream in word by word. Newlines are preserved as\n   *  paragraph breaks. Changing it restarts the stream from the first word. */\n  text: string;\n  /** Milliseconds between each word appearing. */\n  speed?: number;\n  /** Milliseconds to wait before the first word appears. */\n  delay?: number;\n  /** How long a word wears the blue gradient before settling into the\n   *  theme foreground, in milliseconds. */\n  settleDelay?: number;\n  /** Show a small pulsing gradient dot riding the stream head. */\n  showCursor?: boolean;\n  /** Called once, after the last word has appeared. */\n  onComplete?: () => void;\n}\n\ntype StreamToken =\n  | { type: \"word\"; value: string }\n  | { type: \"break\"; value: string };\n\nconst lineBreaks = /(\\n+)/;\nconst whitespace = /\\s+/;\n\n/** Split text into word and line-break tokens. Runs of spaces collapse to\n *  one; runs of newlines collapse to a single paragraph break. Each token\n *  costs one streaming tick, so a paragraph break reads as a natural beat. */\nfunction tokenize(text: string): StreamToken[] {\n  const tokens: StreamToken[] = [];\n  for (const part of text.split(lineBreaks)) {\n    if (part.startsWith(\"\\n\")) {\n      tokens.push({ type: \"break\", value: part.length > 1 ? \"\\n\\n\" : \"\\n\" });\n    } else {\n      for (const word of part.split(whitespace)) {\n        if (word) {\n          tokens.push({ type: \"word\", value: word });\n        }\n      }\n    }\n  }\n  return tokens;\n}\n\n/** Freshly streamed words wear this gradient, then crossfade into\n *  text-foreground — black in light mode, white in dark — so the trail\n *  of blue always marks what just arrived. */\nconst gradientText =\n  \"bg-gradient-to-r from-sky-500 via-blue-600 to-indigo-500 bg-clip-text text-transparent dark:from-sky-400 dark:via-blue-400 dark:to-indigo-300\";\n\n/** Consecutive words sample a 3x-wide version of the gradient at stepped\n *  offsets that ping-pong across GRADIENT_PHASES positions, so the blue\n *  trail reads as one continuous ribbon of color flowing over several\n *  words instead of every word restarting at sky. Static paint — the\n *  offset never animates, so it costs nothing per frame. */\nconst GRADIENT_PHASES = 6;\n\nfunction gradientOffset(index: number): string {\n  const cycle = 2 * (GRADIENT_PHASES - 1);\n  const step = index % cycle;\n  const phase = step < GRADIENT_PHASES ? step : cycle - step;\n  return `${(phase / (GRADIENT_PHASES - 1)) * 100}% 50%`;\n}\n\n/** How long the gradient → foreground crossfade runs once a word's settle\n *  delay has elapsed, in seconds. Together with settleDelay this bounds how\n *  many words are blue at once. */\nconst SETTLE_FADE = 0.45;\n\n/** Entrance fade length in seconds. Deliberately much longer than the\n *  default streaming cadence: at any moment four to five words are mid-fade\n *  at staggered opacities, which turns the reveal into one continuous\n *  gradient wave instead of discrete word pops. */\nconst ENTER_FADE = 0.5;\n\nconst settleTransition = (settleDelay: number) => ({\n  delay: settleDelay / 1000,\n  duration: SETTLE_FADE,\n  ease: [0.4, 0, 0.2, 1] as const,\n});\n\n// Memoized so the words already on screen skip React entirely on every\n// streaming tick — only the newly revealed word mounts and animates.\nconst StreamedWord = memo(function StreamedWord({\n  word,\n  index,\n  settleDelay,\n}: {\n  word: string;\n  index: number;\n  settleDelay: number;\n}) {\n  return (\n    // The word is a grid stacking two copies of itself: the gradient copy\n    // on top fades out while the foreground copy fades in, so the color\n    // handoff is a true crossfade and stays theme-correct in both modes.\n    // Every animated value here is opacity — nothing that forces the\n    // browser to repaint text mid-stream — so the stream stays lag-free.\n    <motion.span\n      // The entrance is a plain opacity fade — no movement, no scale, no\n      // blur. Anything else smears or wobbles the glyphs as they land.\n      animate={{ opacity: 1 }}\n      className=\"inline-grid align-baseline\"\n      initial={{ opacity: 0 }}\n      transition={{ duration: ENTER_FADE, ease: [0.25, 0.46, 0.45, 0.94] }}\n    >\n      <motion.span\n        animate={{ opacity: 1 }}\n        className=\"col-start-1 row-start-1 text-foreground\"\n        initial={{ opacity: 0 }}\n        transition={settleTransition(settleDelay)}\n      >\n        {word}\n      </motion.span>\n      <motion.span\n        animate={{ opacity: 0 }}\n        className={cn(\"col-start-1 row-start-1\", gradientText)}\n        initial={{ opacity: 1 }}\n        style={{\n          backgroundSize: \"300% 100%\",\n          backgroundPosition: gradientOffset(index),\n        }}\n        transition={settleTransition(settleDelay)}\n      >\n        {word}\n      </motion.span>\n    </motion.span>\n  );\n});\n\nconst StreamingText = forwardRef<HTMLSpanElement, StreamingTextProps>(\n  (\n    {\n      className,\n      text,\n      speed = 120,\n      delay = 0,\n      settleDelay = 300,\n      showCursor = true,\n      onComplete,\n      ...props\n    },\n    ref\n  ) => {\n    const tokens = useMemo(() => tokenize(text), [text]);\n    const [count, setCount] = useState(0);\n    // Reduced motion skips the stream entirely — the full text renders\n    // static in the foreground color, no gradient, no cursor.\n    const reduceMotion = useReducedMotion() ?? false;\n\n    const onCompleteRef = useRef(onComplete);\n    onCompleteRef.current = onComplete;\n    const startRef = useRef(0);\n\n    // Reset during render, not in an effect: an effect would let one frame\n    // paint the new text sliced by the old count before restarting.\n    const [prevText, setPrevText] = useState(text);\n    if (prevText !== text) {\n      setPrevText(text);\n      setCount(0);\n    }\n\n    useEffect(() => {\n      if (reduceMotion || tokens.length === 0 || count >= tokens.length) {\n        return;\n      }\n      if (count === 0) {\n        startRef.current = performance.now();\n      }\n      // Every token is scheduled against the stream's start time instead of\n      // chaining timeouts, so render latency can never accumulate into a\n      // drifting, stuttering cadence.\n      const target =\n        startRef.current + delay + (count + 1) * Math.max(speed, 16);\n      const timer = setTimeout(\n        () => {\n          setCount(count + 1);\n          // Completion fires from the tick that reveals the last token —\n          // exactly once, immune to effect re-runs after the stream ends.\n          if (count + 1 >= tokens.length) {\n            onCompleteRef.current?.();\n          }\n        },\n        Math.max(0, target - performance.now())\n      );\n      return () => clearTimeout(timer);\n    }, [count, tokens.length, speed, delay, reduceMotion]);\n\n    // Reduced motion shows everything immediately, so completion is\n    // immediate too — consumers chaining UI on onComplete never hang.\n    useEffect(() => {\n      if (reduceMotion && tokens.length > 0) {\n        onCompleteRef.current?.();\n      }\n    }, [reduceMotion, tokens]);\n\n    const streaming = !reduceMotion && count < tokens.length;\n\n    return (\n      <span\n        className={cn(\n          // Typography is fully inherited: the component only decides how\n          // words arrive and settle, never how they're set. pre-wrap keeps\n          // the paragraph breaks the tokenizer preserves.\n          \"whitespace-pre-wrap text-foreground\",\n          className\n        )}\n        ref={ref}\n        {...props}\n      >\n        {reduceMotion ? (\n          text\n        ) : (\n          <>\n            {/* Screen readers get the full text once; the animated\n                word-by-word display below is decorative. */}\n            <span className=\"sr-only\">{text}</span>\n            <span aria-hidden=\"true\">\n              {/* Positional keys are correct here: the token list is derived\n                  from `text` alone and only ever appends while streaming. */}\n              {tokens.slice(0, count).map((token, index) =>\n                token.type === \"break\" ? (\n                  <span key={`break-${index}`}>{token.value}</span>\n                ) : (\n                  <Fragment key={`word-${index}`}>\n                    <StreamedWord\n                      index={index}\n                      settleDelay={settleDelay}\n                      word={token.value}\n                    />{\" \"}\n                  </Fragment>\n                )\n              )}\n              {showCursor && (\n                <AnimatePresence>\n                  {streaming && (\n                    <motion.span\n                      animate={{ opacity: [0.4, 1, 0.4], scale: 1 }}\n                      className={cn(\n                        \"inline-block size-[0.55em] rounded-full align-baseline\",\n                        \"bg-gradient-to-br from-sky-400 to-indigo-500 dark:from-sky-300 dark:to-indigo-400\",\n                        // The glow rides the dot's animated opacity, so it\n                        // breathes with the pulse at zero per-frame cost.\n                        \"shadow-[0_0_0.7em_rgba(56,189,248,0.55)] dark:shadow-[0_0_0.7em_rgba(125,211,252,0.45)]\"\n                      )}\n                      exit={{ opacity: 0, scale: 0.95 }}\n                      initial={{ opacity: 0, scale: 0.95 }}\n                      transition={{\n                        opacity: {\n                          duration: 1.2,\n                          repeat: Number.POSITIVE_INFINITY,\n                          ease: \"easeInOut\",\n                        },\n                        scale: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },\n                      }}\n                    />\n                  )}\n                </AnimatePresence>\n              )}\n            </span>\n          </>\n        )}\n      </span>\n    );\n  }\n);\n\nStreamingText.displayName = \"StreamingText\";\n\nexport { StreamingText };\nexport type { StreamingTextProps };\nexport default StreamingText;\n",
      "type": "registry:ui"
    }
  ],
  "title": "Streaming Text",
  "description": "Word-by-word AI text streaming where each word fades in crisply wearing a blue gradient, then crossfades into the theme foreground once settled — black in light mode, white in dark — with a pulsing gradient dot at the stream head, natural prose wrapping, sr-only full-text announcement, and reduced-motion aware behavior."
}
