{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-answer","type":"registry:component","title":"Streaming answer","description":"An answer as it arrives, with inline citations, actions, sources and follow-ups.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/streaming-answer.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, Copy, RotateCcw, ThumbsDown, ThumbsUp } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAMING ANSWER: an AI answer as it arrives\n *\n *   StreamingText   the text so far, with inline citation chips;\n *                   only newly arrived words fade in\n *   AnswerActions   copy (blurs into a check that draws itself),\n *                   regenerate, feedback (the thumb fills with a\n *                   small thrown pop) and a sources stack whose\n *                   count rolls\n *   FollowUps       suggested next questions\n *\n * StreamingText renders whatever you pass it: append to `content`\n * as tokens arrive. Blank lines start new paragraphs, and finished\n * paragraphs don't re-render while the last one grows. For Markdown,\n * pass your renderer as children instead of `content`.\n * ───────────────────────────────────────────────────────── */\n\nexport type AnswerCitation = { type: \"citation\"; label: string; href?: string };\nexport type AnswerSegment = string | AnswerCitation;\nexport type AnswerStatus = \"streaming\" | \"done\" | \"stopped\" | \"error\";\nexport type AnswerFeedback = \"up\" | \"down\" | null;\n\nexport interface AnswerSource {\n  name: string;\n  href?: string;\n  /** A favicon or logo, e.g. <img src=\"…/favicon.ico\" alt=\"\" />. */\n  icon?: React.ReactNode;\n  /** Tailwind background class for the dot when there's no icon. */\n  color?: string;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst DOTS = [\"bg-blue-500\", \"bg-emerald-500\", \"bg-orange-500\", \"bg-violet-500\"];\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/* ── StreamingText ───────────────────────────────────────── */\n\nexport interface StreamingTextProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"content\"> {\n  /** The answer so far. Append as tokens arrive. */\n  content?: AnswerSegment[];\n  status?: AnswerStatus;\n  /** Shows a retry button when status is \"error\". */\n  onRetry?: () => void;\n  /** Your own renderer (e.g. Markdown) instead of `content`. */\n  children?: React.ReactNode;\n}\n\ntype Token = { kind: \"word\" | \"space\"; text: string } | { kind: \"cite\"; citation: AnswerCitation };\n\nfunction toParagraphs(content: AnswerSegment[]): Token[][] {\n  const paragraphs: Token[][] = [[]];\n  for (const segment of content) {\n    if (typeof segment !== \"string\") {\n      paragraphs[paragraphs.length - 1].push({ kind: \"cite\", citation: segment });\n      continue;\n    }\n    for (const part of segment.split(/(\\n{2,})/)) {\n      if (/^\\n{2,}$/.test(part)) {\n        paragraphs.push([]);\n        continue;\n      }\n      for (const text of part.split(/(\\s+)/)) {\n        if (text) paragraphs[paragraphs.length - 1].push({ kind: /^\\s+$/.test(text) ? \"space\" : \"word\", text });\n      }\n    }\n  }\n  return paragraphs.filter((p, i) => p.length > 0 || i === paragraphs.length - 1);\n}\n\nfunction CitationChip({ citation }: { citation: AnswerCitation }) {\n  const className = `mx-0.5 inline-flex -translate-y-px items-center gap-1 rounded-[5px] border border-border bg-muted px-1.5 py-px align-middle font-mono text-[11px] text-foreground animate-[ui-fade-up_240ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none ${FOCUS}`;\n  const inner = (\n    <>\n      <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-full bg-emerald-500\" />\n      {citation.label}\n    </>\n  );\n  return citation.href ? (\n    <a href={citation.href} target=\"_blank\" rel=\"noreferrer\" className={`${className} transition-colors hover:bg-accent`}>\n      {inner}\n    </a>\n  ) : (\n    <span className={className}>{inner}</span>\n  );\n}\n\nconst Caret = () => (\n  <span\n    aria-hidden=\"true\"\n    className=\"ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] bg-foreground animate-[ui-blink_1s_steps(2,start)_infinite] motion-reduce:animate-none\"\n  />\n);\n\n// Words keep a stable key by position, so only words that just arrived mount and fade in.\nconst Paragraph = React.memo(\n  function Paragraph({ tokens, caret }: { tokens: Token[]; signature: string; caret: boolean }) {\n    return (\n      <p>\n        {tokens.map((token, i) =>\n          token.kind === \"cite\" ? (\n            <CitationChip key={i} citation={token.citation} />\n          ) : token.kind === \"space\" ? (\n            token.text\n          ) : (\n            <span key={i} className=\"animate-[ui-fade-in_420ms_ease-out_both] motion-reduce:animate-none\">\n              {token.text}\n            </span>\n          )\n        )}\n        {caret && <Caret />}\n      </p>\n    );\n  },\n  (a, b) => a.signature === b.signature && a.caret === b.caret\n);\n\nexport function StreamingText({ content = [], status = \"done\", onRetry, children, className = \"\", ...props }: StreamingTextProps) {\n  const streaming = status === \"streaming\";\n  const paragraphs = children === undefined ? toParagraphs(content) : [];\n\n  return (\n    <div aria-busy={streaming} className={`flex flex-col gap-3 text-[13.5px] leading-relaxed text-foreground ${className}`} {...props}>\n      {children ??\n        paragraphs.map((tokens, i) => (\n          <Paragraph\n            key={i}\n            tokens={tokens}\n            signature={tokens.map((t) => (t.kind === \"cite\" ? `\\u0000${t.citation.label}` : t.text)).join(\"\")}\n            caret={streaming && i === paragraphs.length - 1}\n          />\n        ))}\n\n      {status === \"stopped\" && <p className=\"text-xs text-muted-foreground animate-[ui-fade-in_300ms_ease-out_both]\">Stopped</p>}\n\n      {status === \"error\" && (\n        <p className=\"flex items-center gap-1.5 text-xs text-muted-foreground animate-[ui-fade-in_300ms_ease-out_both]\">\n          <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-red-500\" />\n          The answer was interrupted.\n          {onRetry && (\n            <button\n              type=\"button\"\n              onClick={onRetry}\n              className={`rounded px-1 font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${FOCUS}`}\n            >\n              Retry\n            </button>\n          )}\n        </p>\n      )}\n    </div>\n  );\n}\n\n/* ── AnswerActions ───────────────────────────────────────── */\n\nexport interface AnswerActionsProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Text the copy button puts on the clipboard. Hides the button when omitted. */\n  copyText?: string;\n  onRegenerate?: () => void;\n  /** Controlled feedback, e.g. restored from your database. */\n  feedback?: AnswerFeedback;\n  defaultFeedback?: AnswerFeedback;\n  onFeedback?: (value: AnswerFeedback) => void;\n  sources?: AnswerSource[];\n  onSourcesClick?: () => void;\n  /** Disables the buttons, e.g. while the answer is still streaming. */\n  disabled?: boolean;\n}\n\nfunction IconButton({ label, pressed, disabled, onClick, children }: { label: string; pressed?: boolean; disabled?: boolean; onClick: () => void; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={pressed}\n      disabled={disabled}\n      onClick={onClick}\n      className={`relative flex size-7 items-center justify-center rounded-md transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 ${\n        pressed ? \"bg-accent text-foreground\" : \"text-muted-foreground\"\n      } ${FOCUS}`}\n    >\n      {children}\n    </button>\n  );\n}\n\n/** A thumb that fills in with a small thrown pop when chosen, and empties when let go. */\nfunction Thumb({ down, filled, reduced }: { down?: boolean; filled: boolean; reduced: boolean }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (!filled || reduced) return;\n    ref.current?.animate([{ transform: \"scale(0.8) rotate(-8deg)\" }, { transform: \"none\" }], { duration: 420, easing: THROW });\n  }, [filled, reduced]);\n  const Icon = down ? ThumbsDown : ThumbsUp;\n  return (\n    <span ref={ref} className=\"flex\">\n      <Icon className=\"size-3.5\" style={{ fill: filled ? \"currentColor\" : \"transparent\", transition: reduced ? \"none\" : `fill 220ms ${MORPH}` }} />\n    </span>\n  );\n}\n\nexport function AnswerActions({\n  copyText,\n  onRegenerate,\n  feedback,\n  defaultFeedback = null,\n  onFeedback,\n  sources,\n  onSourcesClick,\n  disabled,\n  className = \"\",\n  ...props\n}: AnswerActionsProps) {\n  const reduced = useReducedMotion();\n  const [copied, setCopied] = React.useState(false);\n  const [ownFeedback, setOwnFeedback] = React.useState<AnswerFeedback>(defaultFeedback);\n  const current = feedback !== undefined ? feedback : ownFeedback;\n\n  React.useEffect(() => {\n    if (!copied) return;\n    const timer = window.setTimeout(() => setCopied(false), 1600);\n    return () => window.clearTimeout(timer);\n  }, [copied]);\n\n  const copy = async () => {\n    if (!copyText) return;\n    try {\n      await navigator.clipboard.writeText(copyText);\n      setCopied(true);\n    } catch {\n      // Clipboard can be blocked by the browser; nothing to undo.\n    }\n  };\n\n  const rate = (value: \"up\" | \"down\") => {\n    const next = current === value ? null : value;\n    if (feedback === undefined) setOwnFeedback(next);\n    onFeedback?.(next);\n  };\n\n  const stack = sources && sources.length > 0 && (\n    <>\n      <span className=\"flex items-center -space-x-1.5\" aria-hidden=\"true\">\n        {sources.slice(0, 3).map((s, i) =>\n          s.icon ? (\n            <span key={s.name} className=\"flex size-3.5 items-center justify-center overflow-hidden rounded-full bg-background ring-2 ring-background\">\n              {s.icon}\n            </span>\n          ) : (\n            <span key={s.name} className={`size-3.5 rounded-full ring-2 ring-background ${s.color ?? DOTS[i % DOTS.length]}`} />\n          )\n        )}\n      </span>\n      <span className=\"inline-flex items-baseline gap-[0.3em] tabular-nums\">\n        <NumberRoll value={sources.length} duration={600} />\n        {sources.length === 1 ? \"source\" : \"sources\"}\n      </span>\n    </>\n  );\n\n  return (\n    <div className={`flex items-center justify-between gap-4 ${className}`} {...props}>\n      <div className=\"flex items-center gap-1\">\n        {copyText !== undefined && (\n          <IconButton label=\"Copy answer\" disabled={disabled} onClick={copy}>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(!copied, reduced)}>\n              <Copy className=\"size-3.5\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center text-emerald-600 dark:text-emerald-400\" style={swap(copied, reduced)}>\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n                <path\n                  d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.8\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  pathLength={1}\n                  strokeDasharray={1}\n                  style={{ strokeDashoffset: copied ? 0 : 1, transition: copied && !reduced ? `stroke-dashoffset 380ms ${MORPH} 100ms` : \"none\" }}\n                />\n              </svg>\n            </span>\n          </IconButton>\n        )}\n        {onRegenerate && (\n          <IconButton label=\"Regenerate\" disabled={disabled} onClick={onRegenerate}>\n            <RotateCcw className=\"size-3.5\" />\n          </IconButton>\n        )}\n        <IconButton label=\"Helpful\" pressed={current === \"up\"} disabled={disabled} onClick={() => rate(\"up\")}>\n          <Thumb filled={current === \"up\"} reduced={reduced} />\n        </IconButton>\n        <IconButton label=\"Not helpful\" pressed={current === \"down\"} disabled={disabled} onClick={() => rate(\"down\")}>\n          <Thumb down filled={current === \"down\"} reduced={reduced} />\n        </IconButton>\n      </div>\n      {/* Outside the buttons, so their names stay their labels. */}\n      <span role=\"status\" className=\"sr-only\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\n\n      {stack &&\n        (onSourcesClick ? (\n          <button\n            type=\"button\"\n            onClick={onSourcesClick}\n            className={`flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[11.5px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n          >\n            {stack}\n          </button>\n        ) : (\n          <span className=\"flex items-center gap-1.5 text-[11.5px] font-medium text-muted-foreground\">{stack}</span>\n        ))}\n    </div>\n  );\n}\n\n/* ── FollowUps ───────────────────────────────────────────── */\n\nexport interface FollowUpsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  items: string[];\n  onSelect?: (item: string) => void;\n  label?: string;\n}\n\nexport function FollowUps({ items, onSelect, label = \"Follow-ups\", className = \"\", ...props }: FollowUpsProps) {\n  return (\n    <div className={`border-t border-border pt-3 ${className}`} {...props}>\n      <p className=\"mb-1 text-[11.5px] font-medium text-muted-foreground\">{label}</p>\n      <ul className=\"flex flex-col\">\n        {items.map((item) => (\n          <li key={item}>\n            <button\n              type=\"button\"\n              onClick={() => onSelect?.(item)}\n              className={`-mx-1.5 flex w-[calc(100%+0.75rem)] items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-[12.5px] text-foreground transition-colors duration-150 hover:bg-accent ${FOCUS}`}\n            >\n              <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n                ↳\n              </span>\n              {item}\n            </button>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}},"@keyframes ui-blink":{"0%, 100%":{"opacity":"1"},"50%":{"opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/streaming-answer"}