# Streaming Text

> Render a reply as it arrives, then offer the actions that apply to it, such as copy, try again, feedback, and its sources.

Source: https://ai.honestui.com/docs/components/streaming-text

```tsx
"use client"

import * as React from "react"

import {
  StreamingCitation,
  StreamingFollowUps,
  StreamingText,
  useSmoothStream,
  type StreamingTextFeedback,
} from "@/components/ai/streaming-text"

// Sample reply, cut into the uneven chunks a real stream delivers.
const sampleChunks = [
  "A focus indicator needs ",
  "a contrast ratio of at least 3:1 against the colors ",
  "next to it. ",
  "The same ratio applies to the boundaries of controls.",
]
const sampleText = sampleChunks.join("")

const sampleSources = [
  {
    href: "https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html",
    title: "Understanding Focus Appearance",
    domain: "w3.org",
  },
  {
    href: "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html",
    title: "Understanding Non-text Contrast",
    domain: "w3.org",
  },
]

const CHUNK_MILLISECONDS = 420

/**
 * No model streams into this preview. A timer delivers one chunk at a time,
 * as a network would, and `useSmoothStream` paces what has arrived so it
 * appears at a steady rate. Try again restarts the timer. The line at the
 * bottom shows what each handler received.
 */
export function StreamingTextDemo() {
  const [chunkCount, setChunkCount] = React.useState(0)
  const [received, setReceived] = React.useState("")
  const hasAllChunks = chunkCount >= sampleChunks.length
  const stream = useSmoothStream(sampleChunks.slice(0, chunkCount).join(""))
  const isComplete = hasAllChunks && stream.isSettled

  React.useEffect(() => {
    if (hasAllChunks) return
    const timer = window.setTimeout(() => setChunkCount((count) => count + 1), CHUNK_MILLISECONDS)
    return () => window.clearTimeout(timer)
  }, [chunkCount, hasAllChunks])

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col gap-[var(--hui-space-4)]">
      <StreamingText
        status={isComplete ? "complete" : "streaming"}
        copyText={sampleText}
        sources={sampleSources}
        onRetry={() => {
          setReceived("")
          setChunkCount(0)
        }}
        onFeedbackChange={(feedback: StreamingTextFeedback) =>
          setReceived(`onFeedbackChange received ${JSON.stringify(feedback)}`)
        }
      >
        {stream.text}
        {isComplete && (
          <>
            {" "}
            <StreamingCitation href={sampleSources[1].href}>w3.org</StreamingCitation>
          </>
        )}
      </StreamingText>
      {isComplete && (
        <StreamingFollowUps
          prompts={["Which tokens fail that ratio?", "Show the ring on a dark surface"]}
          onSelect={(prompt) => setReceived(`onSelect received “${prompt}”`)}
        />
      )}
      <p
        role="status"
        className="m-0 min-h-[var(--hui-space-5)] text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)]"
      >
        {received}
      </p>
    </div>
  )
}

```

## Overview [#overview]

Streaming Text shows the reply you have received so far, with a caret while more is on the way. When the reply settles, a row of actions appears under it: copy, try again, two feedback buttons, and a disclosure with the reply's sources.

It renders what it is given. There is no timer inside it and it reveals nothing on its own schedule: append to its children as your stream delivers text, and change `status` when the stream ends or fails.

Each action appears only when you handle it. A reply with no `onRetry` has no retry button, and one with no `onFeedbackChange` has no feedback buttons, so the row never holds a control that does nothing.

## Anatomy [#anatomy]

<Anatomy title="StreamingText">
  <AnatomyItem name="Content" description="The reply, with a caret while it streams" />

  <AnatomyItem name="Error" description="A line under a reply that stopped early" />

  <AnatomyItem name="Actions" description="Copy, Try again, Helpful, Not helpful, and the sources disclosure" />

  <AnatomyItem name="Sources" description="Every source, as links" />

  <AnatomyItem name="StreamingCitation" description="An inline source link, placed where the claim is made" />

  <AnatomyItem name="StreamingFollowUps" description="Prompts the person can send next" />
</Anatomy>

Installing `streaming-text` brings [Button](https://www.honestui.com/docs/components/button) and the shared easing constants in `lib/ease` along as registry dependencies, and installs `motion`.

## Behavior [#behavior]

### Status [#status]

`status` is `streaming`, `complete`, or `error`, and defaults to `complete`.

* `streaming` shows the caret and hides the actions.
* `complete` shows the actions.
* `error` shows a line saying the reply stopped early, and the actions except feedback. The text received so far stays.

### Smooth streaming [#smooth-streaming]

A stream delivers text in uneven chunks, so text rendered as it arrives moves in jumps. `useSmoothStream` paces it. Pass it the text you have received and render the text it returns, which grows at a steady rate, 110 characters a second by default.

The hook only shows text you already have. It never runs ahead of the stream, and it does not delay `onRetry` or any other handler. Its `isSettled` value turns true once everything received is on screen, so wait for both the stream's end and `isSettled` before you set `status` to `complete`. Otherwise the actions appear while the last words are still being revealed.

### Motion [#motion]

When the reply settles, the action row fades up. Buttons spring on press, the copy icon trades places with a check, the sources chevron turns, and the sources open with a slide while each link fades in. When `status` returns to `streaming`, the row fades out.

### Content [#content]

Children can be plain text or the output of a Markdown renderer. The content has the `honest-prose` class, so [Prose](/docs/prose) styles the headings, paragraphs, lists, links, tables, inline code, and code blocks inside it. Installing `streaming-text` brings Prose along. Import it in your global stylesheet, after Tailwind:

```css
@import "../styles/prose.css";
```

[Markdown](/docs/components/markdown) is the renderer in this registry, and it handles text that is still arriving. When the content holds one, Streaming Text leaves the caret to it, so the caret sits at the end of the last line and not under the last block. A [Code View](/docs/components/code-view) inside the content keeps its own styles.

### Copy [#copy]

Passing `copyText` shows Copy, which writes that text to the clipboard. Pass the plain text of the reply, because the rendered children may be elements. `onCopy` replaces the clipboard write with your own function. The icon changes to a check for a moment. If the write is refused or your function throws, "Copy failed" appears beside the actions.

### Try again [#try-again]

Passing `onRetry` shows Try again. It calls your handler. Requesting the reply again and resetting `status` are your application's work.

### Feedback [#feedback]

Passing `onFeedbackChange` shows Helpful and Not helpful on a complete reply. One can be chosen at a time, and pressing the chosen one again clears it. Your handler receives `"up"`, `"down"`, or `null`. Streaming Text does not send the rating anywhere.

### Sources and follow-ups [#sources-and-follow-ups]

Sources are part of the action row: a button with a count that opens the list. `StreamingFollowUps` is a separate component, so you decide when it appears. Choosing a follow-up calls `onSelect` with the prompt.

## Accessibility [#accessibility]

The content sets `aria-busy` while it streams and is a polite live region, so added text is announced. Set `announce` to `false` when the reply sits inside something that already announces it, such as a [Chat Thread](/docs/components/chat-thread), which is a log. Leaving both on reads each addition twice.

A screen reader announces a live region in whatever pieces you add. Appending a sentence at a time is easier to follow than a word at a time.

Every action is a button with a name. The feedback buttons report whether they are pressed. The sources button reports whether the list is expanded, and each source is a link. "Copied" and "Copy failed" are announced through a status, and the error line is an alert. Every string comes from `labels`.

The caret is decorative. With reduced motion, `useSmoothStream` returns text as it arrives with no pacing, the action row and the sources fade without moving, and buttons do not spring.

Pacing text makes a live region change many times a second. When you use `useSmoothStream`, set `announce` to `false` and announce the finished reply once, or place the reply in a Chat Thread.

| Key                                  | Action                                                           |
| ------------------------------------ | ---------------------------------------------------------------- |
| <kbd>Tab</kbd>                       | Move across citations, the actions, source links, and follow-ups |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Activate the focused action, or open the sources                 |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/streaming-text&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;, &#x22;ease&#x22;, &#x22;@honestai/prose&#x22;]" />

      
        Install Motion.
      

      ```bash
      npm install motion
      ```

      
        Copy and paste this file into your project.
      

      ### components/ai/streaming-text.tsx

```tsx
"use client"

import * as React from "react"
import {
  Check as CheckIcon,
  ChevronDown as ChevronDownIcon,
  CircleAlert as CircleAlertIcon,
  Copy as CopyIcon,
  CornerDownRight as CornerDownRightIcon,
  Globe as GlobeIcon,
  RotateCcw as RotateCcwIcon,
  ThumbsDown as ThumbsDownIcon,
  ThumbsUp as ThumbsUpIcon,
} from "honestui/icons"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"

export type StreamingTextStatus = "streaming" | "complete" | "error"

export type StreamingTextFeedback = "up" | "down" | null

export type StreamingSource = {
  href: string
  title: string
  domain?: string
}

export type StreamingTextLabels = {
  copy: string
  copied: string
  copyFailed: string
  retry: string
  helpful: string
  notHelpful: string
  sources: (count: number) => string
  error: string
}

export const defaultStreamingTextLabels: StreamingTextLabels = {
  copy: "Copy reply",
  copied: "Copied",
  copyFailed: "Copy failed",
  retry: "Try again",
  helpful: "Helpful",
  notHelpful: "Not helpful",
  sources: (count) => (count === 1 ? "1 source" : `${count} sources`),
  error: "This reply stopped before it finished.",
}

const COPIED_MILLISECONDS = 1600

const smallText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

const interactive =
  "outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)]"

// `prose`, from prose.css, styles the output of a Markdown renderer, so a
// reply needs no wrapper of its own. A Markdown component draws the caret
// itself, at the end of its last line.
const contentStyles = "honest-prose [&:has([data-slot=markdown])>[data-slot=streaming-text-caret]]:hidden"

const DEFAULT_CHARACTERS_PER_SECOND = 110

/**
 * Paces text that arrives in uneven chunks, so it appears at a steady rate.
 * It only ever shows text you have already received, and it catches up when
 * the stream ends. With reduced motion it returns the text as it is.
 *
 * `isSettled` turns true once everything received has been shown, which is
 * the moment to mark a reply complete.
 */
function useSmoothStream(
  text: string,
  { charactersPerSecond = DEFAULT_CHARACTERS_PER_SECOND }: { charactersPerSecond?: number } = {}
) {
  const shouldReduceMotion = useReducedMotion() ?? false
  const [shownLength, setShownLength] = React.useState(0)
  // Text that got shorter is a new reply, so the reveal starts over from its length.
  if (shownLength > text.length) setShownLength(text.length)
  const visibleLength = Math.min(shownLength, text.length)
  const isBehind = visibleLength < text.length

  React.useEffect(() => {
    if (shouldReduceMotion || !isBehind) return

    let frame = 0
    let previousTime: number | undefined
    let position = visibleLength

    const step = (time: number) => {
      const elapsed = previousTime === undefined ? 0 : time - previousTime
      previousTime = time
      position = Math.min(position + (elapsed / 1000) * charactersPerSecond, text.length)
      setShownLength(Math.floor(position))
      if (position < text.length) frame = window.requestAnimationFrame(step)
    }

    frame = window.requestAnimationFrame(step)
    return () => window.cancelAnimationFrame(frame)
    // `visibleLength` is read once per run as the starting point; listing it would restart the loop every frame.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text, charactersPerSecond, shouldReduceMotion, isBehind])

  if (shouldReduceMotion) return { text, isSettled: true }

  return { text: text.slice(0, visibleLength), isSettled: !isBehind }
}

/** An icon button in the action row, with a spring on press. */
function ActionButton({
  label,
  isPressed,
  className,
  children,
  onClick,
}: {
  label: string
  /** Set on toggle buttons only. */
  isPressed?: boolean
  className?: string
  children: React.ReactNode
  onClick: () => void
}) {
  const shouldReduceMotion = useReducedMotion() ?? false

  return (
    <motion.button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={isPressed}
      onClick={onClick}
      whileTap={shouldReduceMotion ? undefined : { scale: 0.9 }}
      transition={SPRING_PRESS}
      className={cn(
        buttonVariants({ variant: "link", size: "icon-sm" }),
        // The spring handles the press, so the CSS press scale is turned off.
        "active:scale-100! aria-pressed:bg-[var(--hui-color-background-neutral-secondary)]",
        className
      )}
    >
      {children}
    </motion.button>
  )
}

export type StreamingTextProps = Omit<React.ComponentProps<"div">, "onCopy"> & {
  /** The reply so far: plain text, or the output of a Markdown renderer. */
  children: React.ReactNode
  /** Defaults to "complete". Set "streaming" while text is still arriving. */
  status?: StreamingTextStatus
  /** The plain text the Copy action puts on the clipboard. Passing it shows Copy. */
  copyText?: string
  /** Replaces the clipboard write. Throw, or reject, to report a failed copy. */
  onCopy?: () => void | Promise<void>
  /** Passing it shows Try again once the reply has settled. */
  onRetry?: () => void
  /** Shown as a disclosure under the reply once it has settled. */
  sources?: StreamingSource[]
  sourcesOpen?: boolean
  defaultSourcesOpen?: boolean
  onSourcesOpenChange?: (open: boolean) => void
  feedback?: StreamingTextFeedback
  defaultFeedback?: StreamingTextFeedback
  /** Passing it shows the two feedback buttons on a complete reply. */
  onFeedbackChange?: (feedback: StreamingTextFeedback) => void
  /** Set false when a surrounding log, such as Chat Thread, already announces the text. */
  announce?: boolean
  /** Hides the actions and the sources without changing `status`. */
  showActions?: boolean
  contentClassName?: string
  labels?: Partial<StreamingTextLabels>
}

/**
 * A reply as it arrives, then the actions that apply to it: copy, try again,
 * feedback, and its sources. It renders the text it is given and reveals
 * nothing on its own schedule. Each action appears only when you handle it.
 */
function StreamingText({
  children,
  status = "complete",
  copyText,
  onCopy,
  onRetry,
  sources = [],
  sourcesOpen,
  defaultSourcesOpen = false,
  onSourcesOpenChange,
  feedback: feedbackProp,
  defaultFeedback = null,
  onFeedbackChange,
  announce = true,
  showActions = true,
  contentClassName,
  labels: labelsProp,
  className,
  ...props
}: StreamingTextProps) {
  const labels = React.useMemo(
    () => ({ ...defaultStreamingTextLabels, ...labelsProp }),
    [labelsProp]
  )
  const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle")
  const [uncontrolledFeedback, setUncontrolledFeedback] = React.useState(defaultFeedback)
  const [uncontrolledSourcesOpen, setUncontrolledSourcesOpen] = React.useState(defaultSourcesOpen)
  const shouldReduceMotion = useReducedMotion() ?? false
  const sourcesId = React.useId()
  const copyTimer = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(copyTimer.current), [])

  const isStreaming = status === "streaming"
  const feedback = feedbackProp !== undefined ? feedbackProp : uncontrolledFeedback
  const canCopy = copyText !== undefined || onCopy !== undefined
  // Feedback is for a reply that finished, and only when something records it.
  const canRate = status === "complete" && (onFeedbackChange !== undefined || feedbackProp !== undefined)
  const hasSources = sources.length > 0
  const isSourcesOpen = sourcesOpen ?? uncontrolledSourcesOpen
  const hasActions =
    showActions && !isStreaming && (canCopy || onRetry !== undefined || canRate || hasSources)

  const copy = async () => {
    try {
      if (onCopy) await onCopy()
      else await navigator.clipboard.writeText(copyText ?? "")
      setCopyState("copied")
    } catch {
      setCopyState("failed")
    }
    window.clearTimeout(copyTimer.current)
    copyTimer.current = window.setTimeout(() => setCopyState("idle"), COPIED_MILLISECONDS)
  }

  const toggleSources = () => {
    if (sourcesOpen === undefined) setUncontrolledSourcesOpen(!isSourcesOpen)
    onSourcesOpenChange?.(!isSourcesOpen)
  }

  const rate = (value: "up" | "down") => {
    // Pressing the chosen rating again clears it.
    const next = feedback === value ? null : value
    if (feedbackProp === undefined) setUncontrolledFeedback(next)
    onFeedbackChange?.(next)
  }

  return (
    <div
      data-slot="streaming-text"
      data-status={status}
      aria-busy={isStreaming}
      className={cn("flex w-full min-w-0 flex-col gap-[var(--hui-space-3)]", className)}
      {...props}
    >
      <div
        data-slot="streaming-text-content"
        aria-live={announce ? "polite" : "off"}
        className={cn(contentStyles, contentClassName)}
      >
        {children}
        {isStreaming && (
          <span
            aria-hidden="true"
            data-slot="streaming-text-caret"
            className="ms-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] rounded-full bg-[var(--hui-color-foreground-base-primary)] "
          />
        )}
      </div>

      {status === "error" && (
        <p role="alert" className={cn("m-0 flex items-start gap-[var(--hui-space-2)]", smallText)}>
          <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round"
            aria-hidden="true"
            className="mt-px size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
          />
          <span className="min-w-0 break-words">{labels.error}</span>
        </p>
      )}

      <AnimatePresence initial={false}>
        {hasActions && (
          <motion.div
            key="actions"
            data-slot="streaming-text-actions"
            initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: shouldReduceMotion ? 0.12 : 0.22, ease: EASE_OUT }}
            className={smallText}
          >
            <div className="flex min-w-0 flex-wrap items-center gap-[var(--hui-space-1)]">
              {canCopy && (
                <ActionButton label={labels.copy} onClick={() => void copy()}>
                  {/* The two icons trade places, so the change reads as a result of the press. */}
                  <AnimatePresence initial={false} mode="popLayout">
                    <motion.span
                      key={copyState === "copied" ? "copied" : "copy"}
                      aria-hidden="true"
                      initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
                      transition={shouldReduceMotion ? { duration: 0.1 } : SPRING_SWAP}
                      className="flex [&_svg]:size-4"
                    >
                      {copyState === "copied" ? <CheckIcon /> : <CopyIcon />}
                    </motion.span>
                  </AnimatePresence>
                </ActionButton>
              )}
              {onRetry && (
                <ActionButton label={labels.retry} onClick={onRetry}>
                  <RotateCcwIcon aria-hidden="true" />
                </ActionButton>
              )}
              {canRate && (
                <>
                  <ActionButton
                    label={labels.helpful}
                    isPressed={feedback === "up"}
                    onClick={() => rate("up")}
                  >
                    <ThumbsUpIcon aria-hidden="true" />
                  </ActionButton>
                  <ActionButton
                    label={labels.notHelpful}
                    isPressed={feedback === "down"}
                    onClick={() => rate("down")}
                  >
                    <ThumbsDownIcon aria-hidden="true" />
                  </ActionButton>
                </>
              )}
              {hasSources && (
                <button
                  type="button"
                  data-slot="streaming-text-sources-trigger"
                  aria-expanded={isSourcesOpen}
                  aria-controls={sourcesId}
                  onClick={toggleSources}
                  className={cn(
                    "ms-[var(--hui-space-1)] inline-flex cursor-pointer items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)]",
                    interactive
                  )}
                >
                  <GlobeIcon aria-hidden="true" className="size-3.5 shrink-0" />
                  {labels.sources(sources.length)}
                  <motion.span
                    aria-hidden="true"
                    initial={false}
                    animate={{ rotate: isSourcesOpen ? 180 : 0 }}
                    transition={shouldReduceMotion ? { duration: 0 } : SPRING_SWAP}
                    className="flex"
                  >
                    <ChevronDownIcon className="size-3.5 shrink-0" />
                  </motion.span>
                </button>
              )}
              {/* Mounted with the actions, so a copy is announced when it happens. */}
              <span role="status" className={copyState === "failed" ? "ms-[var(--hui-space-2)]" : "sr-only"}>
                {copyState === "copied" ? labels.copied : copyState === "failed" ? labels.copyFailed : ""}
              </span>
            </div>

            {hasSources && (
              // The wrapper is always mounted, so the button's `aria-controls` always points at something.
              <div id={sourcesId}>
                <AnimatePresence initial={false}>
                  {isSourcesOpen && (
                    <motion.div
                      key="sources"
                      initial={shouldReduceMotion ? { opacity: 0 } : { height: 0, opacity: 0, y: -4 }}
                      animate={shouldReduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1, y: 0 }}
                      exit={shouldReduceMotion ? { opacity: 0 } : { height: 0, opacity: 0, y: -4 }}
                      transition={{ duration: shouldReduceMotion ? 0.12 : 0.22, ease: EASE_OUT }}
                      className="overflow-hidden"
                    >
                      <ul
                        role="list"
                        data-slot="streaming-text-sources"
                        className="m-0 mt-[var(--hui-space-2)] flex list-none flex-col rounded-[var(--hui-radius-3)] bg-[var(--hui-color-background-base-secondary)] p-[var(--hui-space-2)]"
                      >
                        {sources.map((source, index) => (
                          <motion.li
                            key={source.href}
                            initial={shouldReduceMotion ? false : { opacity: 0, y: 6 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={{ duration: 0.22, ease: EASE_OUT, delay: 0.04 * index }}
                            className="min-w-0"
                          >
                            <a
                              href={source.href}
                              className={cn(
                                "flex min-w-0 items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] px-[var(--hui-space-3)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] no-underline!",
                                interactive
                              )}
                            >
                              <span className="min-w-0 truncate underline decoration-[var(--hui-color-border-base-secondary)] underline-offset-2">
                                {source.title}
                              </span>
                              {source.domain && (
                                <span className="ms-auto shrink-0 font-mono text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mono-mini)]">
                                  {source.domain}
                                </span>
                              )}
                            </a>
                          </motion.li>
                        ))}
                      </ul>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}

export type StreamingCitationProps = React.ComponentProps<"a"> & {
  href: string
}

/** An inline source link, placed in the text where the claim is made. */
function StreamingCitation({ className, children, ...props }: StreamingCitationProps) {
  return (
    <a
      data-slot="streaming-citation"
      className={cn(
        "mx-0.5 inline-flex items-center gap-[var(--hui-space-1)] rounded-[var(--hui-radius-1)] bg-[var(--hui-color-background-base-secondary)] px-[var(--hui-space-2)] align-baseline font-mono text-[var(--hui-color-foreground-base-secondary)] no-underline! [font-size:var(--hui-font-size-mono-mini)] [line-height:var(--hui-line-height-small)] hover:text-[var(--hui-color-foreground-base-primary)]",
        interactive,
        className
      )}
      {...props}
    >
      {children}
    </a>
  )
}

export type StreamingFollowUpsProps = Omit<React.ComponentProps<"div">, "children" | "onSelect"> & {
  /** Suggested next prompts. */
  prompts: string[]
  onSelect: (prompt: string) => void
  /** The heading above the prompts. */
  label?: string
}

/** Prompts the person can send next. Render it once the reply has finished. */
function StreamingFollowUps({
  prompts,
  onSelect,
  label = "Follow-ups",
  className,
  ...props
}: StreamingFollowUpsProps) {
  const headingId = React.useId()
  if (prompts.length === 0) return null

  return (
    <div data-slot="streaming-follow-ups" className={cn("min-w-0", smallText, className)} {...props}>
      <p
        id={headingId}
        className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-weight:var(--hui-font-weight-medium)]"
      >
        {label}
      </p>
      <ul
        role="list"
        aria-labelledby={headingId}
        className="m-0 mt-[var(--hui-space-2)] flex list-none flex-col gap-[var(--hui-space-1)] p-0"
      >
        {prompts.map((prompt) => (
          <li key={prompt}>
            <button
              type="button"
              onClick={() => onSelect(prompt)}
              className={cn(
                "-mx-[var(--hui-space-2)] flex w-[calc(100%+var(--hui-space-2)*2)] cursor-pointer items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-2)] py-[var(--hui-space-2)] text-left text-[var(--hui-color-foreground-base-primary)]",
                interactive
              )}
            >
              <CornerDownRightIcon
                aria-hidden="true"
                className="size-3.5 shrink-0 text-[var(--hui-color-foreground-base-tertiary)]"
              />
              <span className="min-w-0 break-words">{prompt}</span>
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export { StreamingText, StreamingCitation, StreamingFollowUps, useSmoothStream }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  StreamingFollowUps,
  StreamingText,
  useSmoothStream,
} from "@/components/ai/streaming-text"

const stream = useSmoothStream(reply.text)
const status = reply.isDone && stream.isSettled ? "complete" : reply.hasFailed ? "error" : "streaming"

<StreamingText
  status={status}
  copyText={reply.text}
  sources={reply.sources}
  onRetry={() => chat.regenerate(reply.id)}
  onFeedbackChange={(feedback) => chat.rate(reply.id, feedback)}
>
  <Markdown>{stream.text}</Markdown>
</StreamingText>

{status === "complete" && (
  <StreamingFollowUps prompts={reply.followUps} onSelect={(prompt) => chat.send(prompt)} />
)}
```

Inside a Chat Thread, turn off the reply's own announcements:

```tsx
<ChatMessage role="assistant" author="Billing agent">
  <StreamingText status={reply.status} announce={false}>
    {reply.text}
  </StreamingText>
</ChatMessage>
```

## Examples [#examples]

### A finished reply with formatted content [#a-finished-reply-with-formatted-content]

Paragraphs, inline code, and a list, with the sources open from the start.

```tsx
import { StreamingText } from "@/components/ai/streaming-text"

export function StreamingTextStaticExample() {
  return (
    <div className="w-full max-w-md min-w-0">
      <StreamingText
        defaultSourcesOpen
        copyText="The export rounds each row before it sums them. Use roundTotal after the sum."
        sources={[{ href: "/docs/components/tool-chips", title: "Tool Chips", domain: "ai.honestui.com" }]}
      >
        <p>
          The export rounds each row before it sums them, so totals drift by a cent on long invoices.
        </p>
        <p>
          Call <code>roundTotal</code> once, after the sum:
        </p>
        <ul>
          <li>Totals match the ledger.</li>
          <li>All 14 existing tests still pass.</li>
        </ul>
      </StreamingText>
    </div>
  )
}

```

### A reply that stopped early [#a-reply-that-stopped-early]

The error status keeps the partial text and offers Try again. This preview has no model: Try again swaps in the full reply after a pause.

```tsx
"use client"

import * as React from "react"

import { StreamingText, type StreamingTextStatus } from "@/components/ai/streaming-text"

const partialText = "The migration adds a region column and then"
const fullText = "The migration adds a region column and then backfills 1,204 rows."

/** No request is made. Try again swaps in the full reply after a pause, as a second attempt would. */
export function StreamingTextErrorExample() {
  const [status, setStatus] = React.useState<StreamingTextStatus>("error")
  const pending = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(pending.current), [])

  return (
    <div className="w-full max-w-md min-w-0">
      <StreamingText
        status={status}
        labels={{ error: "The connection dropped, so this reply is incomplete." }}
        onRetry={() => {
          setStatus("streaming")
          pending.current = window.setTimeout(() => setStatus("complete"), 900)
        }}
      >
        {status === "complete" ? fullText : partialText}
      </StreamingText>
    </div>
  )
}

```

## API reference [#api-reference]

### StreamingText [#streamingtext]

Accepts native div props beyond those listed. Its children are the reply so far.

| Prop                  | Type                                   | Default      | Description                                           |
| --------------------- | -------------------------------------- | ------------ | ----------------------------------------------------- |
| `status`              | `"streaming" \| "complete" \| "error"` | `"complete"` | Where the reply stands.                               |
| `copyText`            | string                                 | None         | The text Copy writes. Passing it shows Copy.          |
| `onCopy`              | `() => void \| Promise<void>`          | None         | Replaces the clipboard write. Passing it shows Copy.  |
| `onRetry`             | `() => void`                           | None         | Passing it shows Try again.                           |
| `sources`             | `StreamingSource[]`                    | `[]`         | Each has `href`, `title`, and an optional `domain`.   |
| `sourcesOpen`         | boolean                                | None         | Whether the sources are shown, when you control it.   |
| `defaultSourcesOpen`  | boolean                                | `false`      | Starts with the sources shown.                        |
| `onSourcesOpenChange` | `(open: boolean) => void`              | None         | Runs when the sources are opened or closed.           |
| `feedback`            | `"up" \| "down" \| null`               | None         | The rating, when you control it.                      |
| `defaultFeedback`     | `"up" \| "down" \| null`               | `null`       | The initial rating.                                   |
| `onFeedbackChange`    | `(feedback) => void`                   | None         | Passing it shows the feedback buttons.                |
| `announce`            | boolean                                | `true`       | Makes the content a polite live region.               |
| `showActions`         | boolean                                | `true`       | Shows the actions and sources once the reply settles. |
| `contentClassName`    | string                                 | None         | Added to the content.                                 |
| `labels`              | `Partial<StreamingTextLabels>`         | English      | Action names, the sources count, and the error line.  |

### useSmoothStream [#usesmoothstream]

`useSmoothStream(text, options)` returns `{ text, isSettled }`.

| Option                | Type   | Default | Description                         |
| --------------------- | ------ | ------- | ----------------------------------- |
| `charactersPerSecond` | number | `110`   | How fast received text is revealed. |

### StreamingCitation [#streamingcitation]

Accepts native anchor props. `href` is required.

### StreamingFollowUps [#streamingfollowups]

| Prop       | Type                       | Default        | Description                    |
| ---------- | -------------------------- | -------------- | ------------------------------ |
| `prompts`  | `string[]`                 | Required       | The suggested prompts.         |
| `onSelect` | `(prompt: string) => void` | Required       | Runs when a prompt is chosen.  |
| `label`    | string                     | `"Follow-ups"` | The heading above the prompts. |
