{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "type": "registry:component",
  "title": "Streaming text",
  "description": "Streamed answer with inline sources, actions, and follow-ups.",
  "categories": [
    "ai-interface"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/provider.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/streaming-text/streaming-text.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ProvidedImage } from \"../lib/provider\";\nimport { cn } from \"../lib/utils\";\n\n/* a 1px ring that keeps a favicon's edge visible on either theme */\nconst avatarRing = \"shadow-[0_0_0_1px_oklch(0.21_0.034_263.436/0.1)] dark:shadow-[0_0_0_1px_oklch(1_0_0/0.08)]\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAMING TEXT\n * Words resolve out of blur, inline citations appear in\n * context, then actions and follow-up prompts become usable.\n * ───────────────────────────────────────────────────────── */\n\nconst WORD_MS = 55;\nconst HOLD_MS = 3400;\n\n/* one streamed word, or a `cite` placeholder that renders an inline source chip */\nexport type StreamingToken = { text: string; cite?: boolean };\n\n/* an Entrolytics answer about the week's traffic */\nconst TOKENS: StreamingToken[] = [\n  ...\"The /pricing page on capydb.dev drew 4,210 visitors this week — more than any other page, and 62% of them on desktop.\"\n    .split(\" \")\n    .map((text) => ({ text })),\n  { text: \"\", cite: true },\n  ...\"The getting-started docs follow with 2,980 visitors.\".split(\" \").map((text) => ({ text })),\n];\n\nconst FOLLOW_UPS = [\"Which pages lead on mobile\", \"Compare capydb.dev and notdocs.dev visitors\"];\n\n/* flat, neutral report marks drawn inline, so nothing loads from the network */\nconst SOURCE_IMAGES = {\n  pages:\n    \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%236f7f97'/%3E%3Crect x='16' y='20' width='32' height='6' rx='3' fill='%23fff'/%3E%3Crect x='16' y='31' width='24' height='6' rx='3' fill='%23fff'/%3E%3Crect x='16' y='42' width='28' height='6' rx='3' fill='%23fff'/%3E%3C/svg%3E\",\n  trend:\n    \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%238796ab'/%3E%3Cpath d='M16 44 28 32l8 7 12-15' fill='none' stroke='%23fff' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\",\n  funnel:\n    \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%235c6b82'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3C/svg%3E\",\n};\n\n/* one cited source rendered as an inline chip and in the sources list */\nexport type StreamingSource = { name: string; domain: string; href: string; image: string };\n\nconst SOURCES: StreamingSource[] = [\n  { name: \"Top pages\", domain: \"pages.example\", href: \"#\", image: SOURCE_IMAGES.pages },\n  { name: \"Weekly visitors\", domain: \"visitors.example\", href: \"#\", image: SOURCE_IMAGES.trend },\n  { name: \"Signup funnel\", domain: \"funnels.example\", href: \"#\", image: SOURCE_IMAGES.funnel },\n];\n\nfunction sourceImage(source: StreamingSource) {\n  return source.image;\n}\n\nfunction SourceChip({ source }: { source?: StreamingSource }) {\n  if (!source) return null;\n  return (\n    <a\n      href={source.href}\n      target=\"_blank\"\n      rel=\"noreferrer\"\n      className=\"ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] animate-streaming-text-cite-in items-center gap-1 rounded-button\n        bg-inset pr-[5px] pl-[5px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline\n        transition-colors duration-150 hover:bg-hover hover:text-ink\"\n    >\n      <ProvidedImage\n        src={sourceImage(source)}\n        alt=\"\"\n        width={12}\n        height={12}\n        className={cn(\"size-3 rounded-[3px]\", avatarRing)}\n      />\n      <span>{source.domain}</span>\n    </a>\n  );\n}\n\nconst ACTION_ICONS: React.ReactNode[] = [\n  <g key=\"copy\">\n    <rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2.5\" />\n    <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" />\n  </g>,\n  <path key=\"retry\" d=\"M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6\" />,\n  <path\n    key=\"up\"\n    d=\"M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z\"\n  />,\n  <path\n    key=\"down\"\n    d=\"M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z\"\n  />,\n];\n\nexport type StreamingLabels = {\n  /** label on the collapsed sources toggle */\n  sources: string;\n  /** heading above the follow-up prompts */\n  followUps: string;\n};\n\nconst DEFAULT_LABELS: StreamingLabels = {\n  sources: \"3 sources\",\n  followUps: \"Follow-ups\",\n};\n\nexport default function StreamingText({\n  content = TOKENS,\n  sources = SOURCES,\n  followUps = FOLLOW_UPS,\n  labels,\n  loop = true,\n  fill = false,\n  onDone,\n  onFollowUp,\n}: {\n  variant?: string;\n  /** the streamed tokens; `cite` tokens render an inline source chip */\n  content?: StreamingToken[];\n  /** cited sources shown in the chip, avatar stack, and expanded list */\n  sources?: StreamingSource[];\n  /** follow-up prompt suggestions shown once the stream completes */\n  followUps?: string[];\n  /** prominent copy strings */\n  labels?: Partial<StreamingLabels>;\n  /** restart the stream after a hold; turn off when embedding in a real thread */\n  loop?: boolean;\n  /** fill the parent width instead of the gallery's fixed measure */\n  fill?: boolean;\n  onDone?: () => void;\n  /** fired when a follow-up prompt is chosen */\n  onFollowUp?: (text: string, index: number) => void;\n} = {}) {\n  const l = { ...DEFAULT_LABELS, ...labels };\n  const [count, setCount] = useState(0);\n  const [sourcesOpen, setSourcesOpen] = useState(false);\n  const done = count >= content.length;\n\n  useEffect(() => {\n    if (done && !loop) {\n      onDone?.();\n      return;\n    }\n    const t = setTimeout(() => setCount((c) => (c >= content.length ? 0 : c + 1)), done ? HOLD_MS : WORD_MS);\n    return () => clearTimeout(t);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [count, done, loop]);\n\n  return (\n    <div className={fill ? \"w-full\" : \"min-h-[15.5rem] w-full max-w-95\"}>\n      <p className=\"text-[13px] leading-relaxed text-ink\">\n        {content.slice(0, count).map((token, i) =>\n          token.cite ? (\n            <SourceChip key={i} source={sources[0]} />\n          ) : (\n            <span key={i} className=\"inline\">\n              {token.text}{\" \"}\n            </span>\n          ),\n        )}\n        {!done && (\n          <span className=\"ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 animate-streaming-text-caret-in rounded-full bg-ink\" />\n        )}\n      </p>\n\n      {/* action icons row */}\n      <div\n        className=\"mt-2 flex items-center gap-0.5 transition-opacity duration-400\"\n        style={{ opacity: done ? 1 : 0, pointerEvents: done ? \"auto\" : \"none\" }}\n      >\n        {ACTION_ICONS.map((icon, i) => (\n          <button\n            key={i}\n            type=\"button\"\n            aria-label=\"Action\"\n            className=\"flex size-6 items-center justify-center rounded-button text-ink-3\n              transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2\"\n          >\n            <svg\n              width=\"15\"\n              height=\"15\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.8\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              {icon}\n            </svg>\n          </button>\n        ))}\n        <button\n          type=\"button\"\n          aria-expanded={sourcesOpen}\n          onClick={() => setSourcesOpen((current) => !current)}\n          className=\"ml-1.5 flex items-center gap-1.5 rounded-button px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover\"\n        >\n          <span className=\"flex -space-x-1\">\n            {sources.map((source) => (\n              <ProvidedImage\n                key={`${source.domain}/${source.name}`}\n                src={sourceImage(source)}\n                alt=\"\"\n                width={14}\n                height={14}\n                className={cn(\"size-3.5 rounded-full bg-surface\", avatarRing)}\n              />\n            ))}\n          </span>\n          <span className=\"text-[12px] text-ink-2\">{l.sources}</span>\n        </button>\n      </div>\n\n      <div\n        className=\"grid transition-[grid-template-rows,opacity] duration-300\"\n        style={{\n          gridTemplateRows: done && sourcesOpen ? \"1fr\" : \"0fr\",\n          opacity: done && sourcesOpen ? 1 : 0,\n          transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n        }}\n      >\n        <div className=\"overflow-hidden\">\n          <div className=\"mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline\">\n            {sources.map((source) => (\n              <a\n                key={`${source.domain}/${source.name}`}\n                href={source.href}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                className=\"group/source flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink\"\n              >\n                <ProvidedImage\n                  src={sourceImage(source)}\n                  alt=\"\"\n                  width={16}\n                  height={16}\n                  className={cn(\"size-4 rounded-[4px]\", avatarRing)}\n                />\n                <span className=\"relative inline-block after:absolute after:inset-x-0 after:-bottom-px after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-[280ms] after:ease-link after:content-[''] group-hover/source:after:scale-x-100 group-focus-visible/source:after:scale-x-100\">\n                  {source.name}\n                </span>\n                <span className=\"ml-auto font-mono text-[10.5px] text-ink-3\">{source.domain}</span>\n              </a>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {/* follow-ups */}\n      <div\n        className=\"mt-2.5 transition-opacity duration-400\"\n        style={{ opacity: done ? 1 : 0, pointerEvents: done ? \"auto\" : \"none\" }}\n      >\n        <p className=\"text-[12px] font-medium text-ink-2\">{l.followUps}</p>\n        <div className=\"mt-0.5 flex flex-col\">\n          {followUps.map((text, i) => (\n            <button\n              key={text}\n              onClick={() => onFollowUp?.(text, i)}\n              className={cn(\n                \"-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2\",\n                done ? \"animate-streaming-text-follow-up-in\" : \"opacity-0\",\n              )}\n              style={done ? { animationDelay: `${i * 90}ms` } : undefined}\n            >\n              <svg\n                width=\"11\"\n                height=\"11\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"var(--ink-3)\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                className=\"shrink-0\"\n              >\n                <path d=\"M9 10l-5 5 5 5\" />\n                <path d=\"M20 4v7a4 4 0 0 1-4 4H4\" />\n              </svg>\n              {text}\n            </button>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/streaming-text/streaming-text.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/streaming-text",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "StreamingText",
    "docs": "https://irisui.dev/components/streaming-text"
  }
}
