{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streamed-text",
  "title": "Streamed Text",
  "description": "Text that arrives word by word with a soft fade: a word that waits keeps its place without ink, so nothing under the paragraph moves, and only the text re-renders.",
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/ui/streamed-text.tsx",
      "content": "'use client';\n\nimport { Fragment, useEffect, useState } from 'react';\n\nimport { leadingSpaceOf, wordsOf } from '@/registry/ndeko/lib/words';\n\nconst SHOWN_WORD =\n  'relative opacity-100 transition-opacity duration-300 ease-(--ease-out-strong) starting:opacity-0';\n\nconst WAITING_WORD = 'invisible opacity-0';\n\nconst ARRIVING_WORD =\n  'transition-opacity duration-300 ease-(--ease-out-strong) starting:opacity-0';\n\n/**\n * Counts up to `total`, `perStep` at a time every `stepMs`, while `isRunning`. The count starts again\n * only when the component mounts: a round that plays again has to mount it again.\n */\nexport function useRevealedCount(\n  total: number,\n  isRunning: boolean,\n  stepMs: number,\n  perStep = 1\n) {\n  const [count, setCount] = useState(0);\n  const isTicking = isRunning && count < total;\n\n  useEffect(() => {\n    if (!isTicking) {\n      return;\n    }\n    const timer = setInterval(() => {\n      setCount((current) => Math.min(total, current + perStep));\n    }, stepMs);\n    return () => clearInterval(timer);\n  }, [isTicking, total, stepMs, perStep]);\n\n  return count;\n}\n\nexport function shownCountOf(\n  isStreaming: boolean,\n  count: number,\n  total: number\n) {\n  if (isStreaming) {\n    return count;\n  }\n  return total;\n}\n\n/**\n * A word that waits keeps its place without ink, so a paragraph has its final height from its first\n * word and nothing under it moves while it streams. Text that is not streaming carries no transition.\n */\nexport function inkClassOf(isStreaming: boolean, isShown: boolean) {\n  if (!isStreaming) {\n    return undefined;\n  }\n  if (isShown) {\n    return SHOWN_WORD;\n  }\n  return WAITING_WORD;\n}\n\n/** Sits after the last word that arrived, inside that word, which `inkClassOf` makes `relative`. */\nexport function StreamCaret() {\n  return (\n    <span\n      aria-hidden='true'\n      className='bg-foreground/50 absolute start-full top-[0.2em] ms-0.5 h-[1em] w-0.5 animate-pulse rounded-full'\n    />\n  );\n}\n\nfunction TrailingCaret() {\n  return (\n    <span\n      aria-hidden='true'\n      className='bg-foreground/50 inline-block h-[1em] w-[2px] animate-pulse rounded-full align-[-0.15em]'\n    />\n  );\n}\n\ntype WordsProps = {\n  words: readonly string[];\n  shown: number;\n  isStreaming: boolean;\n};\n\nfunction KeptWords({ words, shown, isStreaming }: WordsProps) {\n  const lastShown = shown - 1;\n\n  return (\n    <>\n      {words.map((word, index) => {\n        const isShown = index < shown;\n        const hasCaret = isStreaming && index === lastShown;\n        return (\n          <Fragment key={index}>\n            {leadingSpaceOf(index)}\n            <span className={inkClassOf(isStreaming, isShown)}>\n              {word}\n              {hasCaret && <StreamCaret />}\n            </span>\n          </Fragment>\n        );\n      })}\n    </>\n  );\n}\n\n/** Each word fades in as it mounts, so a line that grows settles word by word with no script per frame. */\nfunction GrowingWords({ words, shown, isStreaming }: WordsProps) {\n  const arrived = words.slice(0, shown);\n\n  return (\n    <>\n      {arrived.map((word, index) => (\n        <span key={index} className={ARRIVING_WORD}>\n          {word}{' '}\n        </span>\n      ))}\n      {isStreaming && <TrailingCaret />}\n    </>\n  );\n}\n\nexport type StreamedWordsProps = {\n  words: readonly string[];\n  /** How many of the words have arrived. */\n  shown: number;\n  /** More words are coming: a caret follows the last one that arrived. */\n  isStreaming?: boolean;\n  /**\n   * True lays every word out from the start, so nothing under the text moves while it streams and a\n   * text that is not streaming shows all its words. False lets the line grow as its words arrive.\n   */\n  keepsPlace?: boolean;\n  className?: string;\n};\n\nexport function StreamedWords({\n  words,\n  shown,\n  isStreaming = false,\n  keepsPlace = true,\n  className,\n}: StreamedWordsProps) {\n  if (keepsPlace) {\n    return (\n      <span className={className}>\n        <KeptWords words={words} shown={shown} isStreaming={isStreaming} />\n      </span>\n    );\n  }\n  return (\n    <span className={className}>\n      <GrowingWords words={words} shown={shown} isStreaming={isStreaming} />\n    </span>\n  );\n}\n\ntype StreamedTextProps = {\n  text: string;\n  isStreaming: boolean;\n  /** False off screen and in a hidden tab: the text holds where it is. */\n  isActive: boolean;\n  wordMs: number;\n};\n\n/** Only this text re-renders while it streams. */\nexport function StreamedText({\n  text,\n  isStreaming,\n  isActive,\n  wordMs,\n}: StreamedTextProps) {\n  const words = wordsOf(text);\n  const isRunning = isStreaming && isActive;\n  const count = useRevealedCount(words.length, isRunning, wordMs);\n  const shown = shownCountOf(isStreaming, count, words.length);\n\n  return (\n    <StreamedWords words={words} shown={shown} isStreaming={isStreaming} />\n  );\n}\n\nfunction arrivingInkOf(isStreaming: boolean) {\n  if (isStreaming) {\n    return ARRIVING_WORD;\n  }\n  return undefined;\n}\n\nfunction arrivalDelayOf(isStreaming: boolean, index: number, wordMs: number) {\n  if (!isStreaming) {\n    return undefined;\n  }\n  const delay = index * wordMs;\n  return { transitionDelay: `${delay}ms` };\n}\n\ntype StreamedInkProps = {\n  text: string;\n  /** True for a text that has just been written: its words fade in one after the other. */\n  isStreaming: boolean;\n  wordMs: number;\n};\n\n/**\n * Every word is in the page from the start, so the paragraph has its final height at once and a\n * screen reader gets the whole text. Only the ink arrives, each word through its own delay, so\n * nothing ticks in script while the text arrives.\n */\nexport function StreamedInk({ text, isStreaming, wordMs }: StreamedInkProps) {\n  // Split on spaces only: the line breaks of a text shown with `whitespace-pre-line` stay inside their words.\n  const words = text.split(' ');\n\n  return (\n    <>\n      {words.map((word, index) => (\n        <Fragment key={index}>\n          {leadingSpaceOf(index)}\n          <span\n            style={arrivalDelayOf(isStreaming, index, wordMs)}\n            className={arrivingInkOf(isStreaming)}\n          >\n            {word}\n          </span>\n        </Fragment>\n      ))}\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "primitives",
    "ai"
  ],
  "type": "registry:ui"
}