# Streaming Text

> Token-by-token model output with an even reveal cadence, word-group fade/blur-in and a soft caret. Takes a growing string or an async iterable.

- **Slug:** `streaming-text` · **Category:** conversation · **Status:** beta
- **Import:** `import { StreamingChunk, StreamingText, UseStreamingTextOptions, UseStreamingTextResult } from "@kura-ui/react/streaming-text";`
- **Docs:** https://kuro.abstergo.fyi/components/streaming-text
- **Registry item:** https://kuro.abstergo.fyi/r/streaming-text.json
- **Tags:** streaming, tokens, llm, text, typewriter, caret, chat, output

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/streaming-text.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add streaming-text
# npm package
npm install @kura-ui/react
```

## When to use

- Render an LLM answer while it streams in (AI SDK, OpenAI/Anthropic SSE, a ReadableStream adapted to an async iterable).
- Smooth out bursty token delivery so text appears at a calm, even rhythm instead of in jumps.
- Show a live caret at the end of text that is still being generated.
- Build a custom renderer (markdown, rich text) on top of the `useStreamingText` hook exported from the same folder.

## When not to use

- Static text that is already complete — render it directly (StreamingText shows initial text instantly anyway).
- A “Thinking…” label before the first token — use shimmer-text (or reasoning for a collapsible trace).
- A full chat bubble with avatar and actions — use message, which composes streaming-text.
- Streaming code — use code-block with `streaming` so partial lines do not flicker.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `text` | `string` | — | no | The text received so far. Pass a growing string; a change that is not an append restarts the reveal from the common prefix. |
| `stream` | `AsyncIterable<string> \| null` | — | no | An async iterable of chunks (an async generator, AI SDK `textStream`). Takes precedence over `text`. Each iterable is consumed once and shared across re-renders. |
| `speed` | `number` | `90` | no | Minimum reveal cadence in characters per second. Bursts are buffered and released at this rate, faster when a backlog builds up (never more than ~½s behind). `0` disables smoothing. |
| `streaming` | `boolean` | — | no | More text is still coming: keeps the caret visible and `aria-busy` set. Defaults to true while `stream` is being read, otherwise false. |
| `onComplete` | `() => void` | — | no | Called once when the stream has ended and every character has been revealed (useful to enable message actions). |
| `animation` | `"blur" \| "fade" \| "none"` | `"blur"` | no | How each newly revealed word group enters: fade + 3px blur, fade only, or no animation. |
| `caret` | `boolean` | `true` | no | Show a soft pulsing caret at the live edge while streaming. It fades out (without reflow) when the stream completes. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **StreamingText** (`data-slot="streaming-text"`) — Root, `white-space: pre-wrap`. `data-status="streaming" | "done"`, `aria-busy` while streaming.
- **Chunk** (`data-slot="streaming-text-chunk"`) — A recently revealed word group, animating in. Chunks merge into one text node ~0.7s after they appear, so long answers stay a single node.
- **Caret** (`data-slot="streaming-text-caret"`) — Decorative caret (aria-hidden). `data-visible` reflects streaming.

## Accessibility

- The text is real text in the DOM; the root sets `aria-busy` while streaming so a surrounding `role="log"` / live region announces the finished answer instead of every token.
- Do not make StreamingText itself a live region — put it inside message / a `role="log"` list.
- The caret is decorative (`aria-hidden`).
- Reduced motion: Word groups only fade in (no blur) and the caret stops pulsing; the even reveal cadence is kept because it carries meaning (text is still arriving).

## Notes for agents

- Pass the SAME iterable object across renders (store it in state/ref). Creating a new generator in JSX on every render restarts the stream.
- For a growing string, set `streaming` while tokens are still arriving; otherwise the caret hides and `onComplete` fires each time the reveal catches up.
- Text present at mount renders instantly — remounting or hydrating a finished message never replays the animation.
- For markdown, call `useStreamingText({ stream })` and feed `displayed` to your markdown renderer (or render `settled` + `chunks` yourself to keep the per-chunk fade).
- Import the hook from the same entry: `import { StreamingText, useStreamingText } from "@kura-ui/react/streaming-text"`.

## Examples

### Async iterable (minimal)

Pass a token generator; bursty delivery is evened out automatically.

```tsx
"use client";

import * as React from "react";
import { StreamingText } from "@kura-ui/react/streaming-text";

const ANSWER =
  "Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.";

async function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {
  const words = text.split(/(?<=\s)/);
  for (let i = 0; i < words.length; ) {
    if (signal?.aborted) return;
    const n = 1 + Math.floor(Math.random() * 4);
    yield words.slice(i, i + n).join("");
    i += n;
    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));
  }
}

export function StreamingTextMinimal() {
  const [stream] = React.useState(() => fakeTokens(ANSWER));
  return <StreamingText stream={stream} className="max-w-md" />;
}
```

### Growing string, every prop (full)

Drive it with a growing `text` + `streaming`, tune `speed`, and react to `onComplete`.

```tsx
"use client";

import { RotateCcw } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { StreamingText } from "@kura-ui/react/streaming-text";

const ANSWER =
  "Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.";

async function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {
  const words = text.split(/(?<=\s)/);
  for (let i = 0; i < words.length; ) {
    if (signal?.aborted) return;
    const n = 1 + Math.floor(Math.random() * 4);
    yield words.slice(i, i + n).join("");
    i += n;
    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));
  }
}

export function StreamingTextFull() {
  const [run, setRun] = React.useState(0);
  const [text, setText] = React.useState("");
  const [streaming, setStreaming] = React.useState(true);
  const [done, setDone] = React.useState(0);

  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` restarts the fake stream
  React.useEffect(() => {
    const controller = new AbortController();
    setText("");
    setStreaming(true);
    (async () => {
      let acc = "";
      for await (const token of fakeTokens(ANSWER, controller.signal)) {
        acc += token;
        setText(acc);
      }
      if (!controller.signal.aborted) setStreaming(false);
    })();
    return () => controller.abort();
  }, [run]);

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <StreamingText
        text={text}
        streaming={streaming}
        speed={120}
        animation="blur"
        caret
        onComplete={() => setDone((d) => d + 1)}
        className="min-h-32"
      />
      <div className="flex items-center justify-between">
        <span className="font-kura-mono text-kura-fg-subtle text-xs tabular-nums">
          {text.length} chars · completed {done}×
        </span>
        <Button variant="secondary" size="sm" onClick={() => setRun((r) => r + 1)}>
          <RotateCcw /> Replay
        </Button>
      </div>
    </div>
  );
}
```

### Custom renderer with useStreamingText (composition)

Shimmering “Thinking…” until the first token, then a hand-rolled renderer built on the hook, with a Stop button.

```tsx
"use client";

import { Square } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { ShimmerText } from "@kura-ui/react/shimmer-text";
import { useStreamingText } from "@kura-ui/react/streaming-text";

const ANSWER =
  "Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.";

async function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {
  const words = text.split(/(?<=\s)/);
  for (let i = 0; i < words.length; ) {
    if (signal?.aborted) return;
    const n = 1 + Math.floor(Math.random() * 4);
    yield words.slice(i, i + n).join("");
    i += n;
    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));
  }
}

export function StreamingTextComposition() {
  const [stream, setStream] = React.useState<AsyncIterable<string> | null>(null);
  const [stopped, setStopped] = React.useState(false);
  const controller = React.useRef<AbortController | null>(null);

  React.useEffect(() => {
    const id = setTimeout(() => {
      controller.current = new AbortController();
      setStream(fakeTokens(ANSWER, controller.current.signal));
    }, 1200);
    return () => {
      clearTimeout(id);
      controller.current?.abort();
    };
  }, []);

  const { settled, chunks, isStreaming } = useStreamingText({ stream });
  const waiting = !stream;

  return (
    <div className="flex w-full max-w-md flex-col gap-3 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm">
      <div className="flex h-6 items-center justify-between">
        <span className="font-medium text-kura-fg text-sm">Assistant</span>
        {(waiting || isStreaming) && !stopped && (
          <Button
            variant="ghost"
            size="xs"
            onClick={() => {
              controller.current?.abort();
              setStopped(true);
            }}
          >
            <Square className="fill-current" /> Stop
          </Button>
        )}
      </div>
      {waiting ? (
        <p role="status" className="text-[15px] leading-relaxed">
          <ShimmerText>Thinking…</ShimmerText>
        </p>
      ) : (
        <p
          aria-busy={isStreaming || undefined}
          className="whitespace-pre-wrap text-[15px] text-kura-fg leading-relaxed"
        >
          {settled}
          {chunks.map((c) => (
            <span
              key={c.id}
              className="transition-opacity duration-(--kura-duration-slow) starting:opacity-0"
            >
              {c.text}
            </span>
          ))}
        </p>
      )}
    </div>
  );
}
```

## Dependencies

- npm: none
- Kura registry items: `kura-theme`, `utils`
- Related: `message`, `reasoning`, `code-block`, `shimmer-text`
