{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "title": "Streaming Text",
  "description": "Reveals arriving text character by character at reading speed, with a caret that marks where the next word lands.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/streaming-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\n/** Characters per second. Close to a brisk reading pace, so text never races ahead of the eye. */\nconst DEFAULT_SPEED = 60\nconst MIN_SPEED = 1\nconst MS_PER_SECOND = 1000\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\"\n\ninterface StreamingTextProps extends React.ComponentProps<\"span\"> {\n  /** Full text so far. Grow it as tokens arrive; the reveal keeps pace from where it left off. */\n  text: string\n  /** Characters revealed per second. */\n  speed?: number\n  /** Fires once each time every character of `text` has been shown. */\n  onDone?: () => void\n  /** Blinking caret at the end of the visible text while characters are still arriving. */\n  cursor?: boolean\n}\n\ninterface RevealState {\n  readonly text: string\n  readonly count: number\n}\n\n/**\n * Carry the reveal across a `text` change. If what the user has already read is still the\n * prefix of the new text we continue from there; otherwise the text is new and starts over.\n * With reduced motion everything is shown at once, so there is nothing to carry.\n */\nfunction carryOver(previous: RevealState, text: string, reduceMotion: boolean): RevealState {\n  if (reduceMotion) return { text, count: text.length }\n  const shown = previous.text.slice(0, previous.count)\n  return { text, count: text.startsWith(shown) ? previous.count : 0 }\n}\n\n/** Read once on mount, matching motion's hook. motion itself is not needed here, so we skip the dependency. */\nfunction usePrefersReducedMotion() {\n  const [prefersReducedMotion] = useState(\n    () =>\n      typeof window !== \"undefined\" &&\n      typeof window.matchMedia === \"function\" &&\n      window.matchMedia(REDUCED_MOTION_QUERY).matches,\n  )\n  return prefersReducedMotion\n}\n\nfunction StreamingText({\n  text,\n  speed = DEFAULT_SPEED,\n  onDone,\n  cursor = true,\n  className,\n  ...props\n}: StreamingTextProps) {\n  const reduceMotion = usePrefersReducedMotion()\n  const [state, setState] = useState<RevealState>(() => ({ text, count: reduceMotion ? text.length : 0 }))\n  if (state.text !== text) setState((previous) => carryOver(previous, text, reduceMotion))\n\n  const done = state.count >= state.text.length\n  const visible = state.text.slice(0, state.count)\n\n  useEffect(() => {\n    if (done || reduceMotion) return\n    const intervalMs = MS_PER_SECOND / Math.max(speed, MIN_SPEED)\n    const id = window.setInterval(() => {\n      setState((previous) =>\n        previous.count < previous.text.length ? { ...previous, count: previous.count + 1 } : previous,\n      )\n    }, intervalMs)\n    return () => window.clearInterval(id)\n  }, [done, reduceMotion, speed])\n\n  const doneFiredRef = useRef(false)\n  useEffect(() => {\n    if (!done) {\n      doneFiredRef.current = false\n      return\n    }\n    if (doneFiredRef.current) return\n    doneFiredRef.current = true\n    onDone?.()\n  }, [done, onDone])\n\n  return (\n    <span\n      aria-live=\"polite\"\n      aria-busy={!done}\n      data-slot=\"streaming-text\"\n      data-streaming={done ? \"false\" : \"true\"}\n      className={cn(\"whitespace-pre-wrap text-foreground\", className)}\n      {...props}\n    >\n      {visible}\n      {cursor && !done ? (\n        <span\n          aria-hidden=\"true\"\n          data-testid=\"streaming-cursor\"\n          className=\"ml-px inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-pulse rounded-full bg-mise motion-reduce:animate-none\"\n        />\n      ) : null}\n    </span>\n  )\n}\n\nexport { StreamingText, type StreamingTextProps }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}