# Streaming Text (`streaming-text`)

> An AI-response text renderer — tokens materialize out of a blur at stream pace, trailed by a blinking caret that vanishes on completion.

- **Docs:** https://www.mellowui.com/components/streaming-text
- **Markdown:** https://www.mellowui.com/components/streaming-text.md
- **Registry:** https://www.mellowui.com/r/streaming-text.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/streaming-text
- **Categories:** text, ai, animation
- **Dependencies:** motion

## AI prompt

Add a StreamingText component from the mellow library — it renders AI responses by revealing tokens at a stream pace, each word fading out of a blur, with a blinking accent caret that disappears on completion. Pass the full `text` (changing it restarts the stream), tune `speed` (tokens/sec) and `by` ('word' | 'char'), and use `onComplete` to flip UI state when the answer finishes. Respects prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/streaming-text.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | — | The full text to stream in. Changing it restarts the stream. |
| `by` | `"word" \| "char"` | `"word"` | Reveal granularity. |
| `speed` | `number` | `14` | Tokens revealed per second. |
| `startDelay` | `number` | `0` | Delay before streaming starts, in ms. |
| `cursor` | `boolean` | `true` | Show the blinking caret while streaming. |
| `onComplete` | `() => void` | — | Called once when the last token has been revealed. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export interface StreamingTextProps {
  /** The full text to stream in. Changing it restarts the stream. */
  text: string;
  /** Reveal granularity. */
  by?: "word" | "char";
  /** Tokens revealed per second. */
  speed?: number;
  /** Delay before streaming starts, in ms. */
  startDelay?: number;
  /** Show the blinking caret while streaming. */
  cursor?: boolean;
  /** Called once when the last token has been revealed. */
  onComplete?: () => void;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * An AI-response text renderer — tokens materialize out of a blur at stream
 * pace, trailed by a blinking caret that vanishes on completion.
 */
export function StreamingText({
  text,
  by = "word",
  speed = 14,
  startDelay = 0,
  cursor = true,
  onComplete,
  className,
  style,
}: StreamingTextProps) {
  const reduced = useReducedMotion();
  const [count, setCount] = useState(0);
  const completedRef = useRef(false);
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  const tokens = useMemo(
    () => (by === "word" ? text.split(/(?<=\s)/) : Array.from(text)),
    [text, by]
  );

  useEffect(() => {
    setCount(0);
    completedRef.current = false;

    let interval: ReturnType<typeof setInterval> | undefined;
    const timeout = setTimeout(() => {
      interval = setInterval(() => {
        setCount((c) => {
          if (c >= tokens.length) return c;
          return c + 1;
        });
      }, 1000 / Math.max(1, speed));
    }, startDelay);

    return () => {
      clearTimeout(timeout);
      if (interval) clearInterval(interval);
    };
  }, [tokens, speed, startDelay]);

  const done = count >= tokens.length;

  useEffect(() => {
    if (done && tokens.length > 0 && !completedRef.current) {
      completedRef.current = true;
      onCompleteRef.current?.();
    }
  }, [done, tokens.length]);

  return (
    <span
      aria-label={text}
      className={[
        "whitespace-pre-wrap [font-family:var(--font-sans)] text-[var(--ink)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={style}
    >
      <span aria-hidden="true">
        {tokens.slice(0, count).map((token, i) => (
          <motion.span
            key={i}
            initial={
              reduced ? false : { opacity: 0, y: 4, filter: "blur(4px)" }
            }
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: 0.35, ease: "easeOut" }}
            className="inline-block whitespace-pre-wrap"
          >
            {token}
          </motion.span>
        ))}
      </span>
      {cursor && !done && (
        <motion.span
          aria-hidden="true"
          animate={reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}
          transition={{ duration: 0.9, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}
          className="ml-px inline-block h-[1em] w-[2px] translate-y-[0.15em] bg-[oklch(0.65_0.25_250)]"
        />
      )}
    </span>
  );
}

export default StreamingText;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { StreamingText } from "../mellow/streaming-text";

const ANSWER =
  "Streaming is mostly an illusion of care. The model produces tokens either way — but revealing them at a human reading pace turns a wait into a conversation. This component fades each word out of a blur as it arrives, trailed by a caret, so the answer feels written rather than pasted.";

export default function StreamingTextDemo() {
  const [done, setDone] = useState(false);
  const [run, setRun] = useState(0);

  return (
    <div className="w-full max-w-xl px-6 py-10">
      <div className="mb-4 flex items-center justify-between">
        <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
          {done ? "● Response complete" : "◌ Streaming…"}
        </span>
        <button
          type="button"
          onClick={() => {
            setDone(false);
            setRun((r) => r + 1);
          }}
          className="cursor-pointer border border-[var(--rule)] px-2.5 py-1 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase transition-colors hover:bg-[rgba(var(--ink-rgb),0.06)]"
        >
          Replay
        </button>
      </div>
      <StreamingText
        key={run}
        text={ANSWER}
        speed={11}
        startDelay={300}
        onComplete={() => setDone(true)}
        className="text-[0.9375rem] leading-relaxed"
      />
    </div>
  );
}

```
