{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-text","title":"Streaming Text","description":"Streamed answer with inline sources, actions, and follow-ups.","dependencies":[],"registryDependencies":["https://ward.so/r/foundation.json"],"files":[{"path":"registry/ward/StreamingText.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\n\nimport { useEffect, useState } from \"react\";\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\nconst TOKENS: StreamingToken[] = [\n  ...\"Pistachio is your fastest-growing project — sales are up 23% this month and margins beat vanilla by 8 points.\"\n    .split(\" \")\n    .map((text) => ({ text })),\n  { text: \"\", cite: true },\n  ...\"Stone-fruit projects are trending in the same range.\"\n    .split(\" \")\n    .map((text) => ({ text })),\n];\n\nconst FOLLOW_UPS = [\n  \"Which projects sell best in winter\",\n  \"Compare brand and soft serve margins\",\n];\n\nconst SOURCE_IMAGES = {\n  work:\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='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E\",\n  trends:\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='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E\",\n  market:\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='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%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: \"Work Data\", domain: \"example.com\", href: \"https://example.com/source-one\", image: SOURCE_IMAGES.work },\n  { name: \"Trends Index\", domain: \"example.com\", href: \"https://example.com/source-two\", image: SOURCE_IMAGES.trends },\n  { name: \"Market Basket\", domain: \"example.com\", href: \"https://example.com/source-three\", image: SOURCE_IMAGES.market },\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] items-center gap-1 rounded-[5px]\n        bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline\n        transition-colors duration-150 hover:bg-hover hover:text-ink\"\n      style={{ animation: \"pop-in 250ms cubic-bezier(0.23,1,0.32,1) both\" }}\n    >\n      <img src={sourceImage(source)} alt=\"\" className=\"source-avatar size-3 rounded-[3px]\" />\n      <span>{source.domain}</span>\n    </a>\n  );\n}\n\nconst ACTION_ICONS: React.ReactNode[] = [\n  <g key=\"copy\"><rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2.5\" /><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" /></g>,\n  <path key=\"retry\" d=\"M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6\" />,\n  <path key=\"up\" 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  <path key=\"down\" 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\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: \"10 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(\n      () => setCount((c) => (c >= content.length ? 0 : c + 1)),\n      done ? HOLD_MS : WORD_MS,\n    );\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\n            className=\"ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink\"\n            style={{ animation: \"fade-in 150ms ease-out both\" }}\n          />\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-[6px] text-ink-3\n              transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2\"\n          >\n            <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\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-[6px] 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              <img\n                key={source.href}\n                src={sourceImage(source)}\n                alt=\"\"\n                className=\"source-avatar size-3.5 rounded-full bg-card shadow-[0_0_0_1.5px_var(--canvas)]\"\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.href}\n                href={source.href}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                className=\"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                <img src={sourceImage(source)} alt=\"\" className=\"source-avatar size-4 rounded-[4px]\" />\n                <span className=\"animated-underline\">{source.name}</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=\"-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line\n                px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors\n                duration-100 hover:bg-hover-2\"\n              style={\n                done\n                  ? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }\n                  : { opacity: 0 }\n              }\n            >\n              <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"shrink-0\">\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/ward/StreamingText.tsx"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/StreamingText.tsx","access":"free"},"categories":["ai"],"type":"registry:component"}