UI kit

StreamingText

DisplayReact · Next.js

Renders text token-by-token with a blinking cursor. Pass a finished string for a typewriter reveal, or a live stream (an SSE / chunked fetch body) to render tokens as they arrive. Composes cleanly inside RagChat.

<StreamingText />

Live preview

Installation

No dependencies to install — just React + Tailwind CSS. It's a presentational component (no data fetching), so the same source works unchanged in React and Next.js (App or Pages Router) — there's no separate variant. Copy it into your project (e.g. components/StreamingText.tsx) and import it.

Usage

usage.tsx
import { StreamingText } from "@/components/StreamingText";

export default function Example() {
  return (
    <StreamingText text={answer} />
  );
}

Props

PropTypeDefaultDescription
textstringA complete string to reveal token-by-token.
streamAsyncIterable<string> | ReadableStreamA live stream of text chunks.
speednumber16Milliseconds per character when revealing text.
cursorbooleantrueShow the blinking cursor while streaming.
onDone() => voidCalled when the reveal/stream finishes.

Component source copy-paste ready

Presentational component — no data fetching, so the same source works unchanged in React and Next.js. There's no separate Next.js variant to copy.

StreamingText.tsx
"use client";

import { useEffect, useRef, useState } from "react";

interface StreamingTextProps {
  /** A complete string to reveal token-by-token (typewriter). */
  text?: string;
  /** A live stream of text chunks — e.g. a fetch() ReadableStream or any async iterable. */
  stream?: AsyncIterable<string> | ReadableStream<Uint8Array>;
  /** Milliseconds per character when revealing `text`. Default: 16. */
  speed?: number;
  /** Show the blinking cursor while streaming. Default: true. */
  cursor?: boolean;
  className?: string;
  onDone?: () => void;
}

async function* readStream(stream: ReadableStream<Uint8Array>): AsyncIterable<string> {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      yield decoder.decode(value, { stream: true });
    }
  } finally {
    reader.releaseLock();
  }
}

/**
 * StreamingText — renders text token-by-token with a blinking cursor. Pass a
 * finished `text` for a typewriter reveal, or a live `stream` (e.g. an SSE /
 * chunked fetch body) to render tokens as they arrive.
 */
export function StreamingText({
  text,
  stream,
  speed = 16,
  cursor = true,
  className = "",
  onDone,
}: StreamingTextProps) {
  const [out, setOut] = useState("");
  const [done, setDone] = useState(false);
  const onDoneRef = useRef(onDone);

  useEffect(() => {
    onDoneRef.current = onDone;
  }, [onDone]);

  useEffect(() => {
    let cancelled = false;
    setOut("");
    setDone(false);

    async function run() {
      if (text != null) {
        for (let i = 1; i <= text.length; i++) {
          if (cancelled) return;
          setOut(text.slice(0, i));
          await new Promise((r) => setTimeout(r, speed));
        }
      } else if (stream) {
        const iter = stream instanceof ReadableStream ? readStream(stream) : stream;
        for await (const chunk of iter) {
          if (cancelled) return;
          setOut((prev) => prev + chunk);
        }
      }
      if (!cancelled) {
        setDone(true);
        onDoneRef.current?.();
      }
    }

    run();
    return () => {
      cancelled = true;
    };
  }, [text, stream, speed]);

  return (
    <span className={className}>
      {out}
      {cursor && !done && (
        <span
          className="ml-0.5 inline-block w-[2px] animate-pulse bg-current align-text-bottom"
          style={{ height: "1.05em" }}
        />
      )}
    </span>
  );
}