UI kit
StreamingText
DisplayReact · Next.jsRenders 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
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | A complete string to reveal token-by-token. |
stream | AsyncIterable<string> | ReadableStream | — | A live stream of text chunks. |
speed | number | 16 | Milliseconds per character when revealing text. |
cursor | boolean | true | Show the blinking cursor while streaming. |
onDone | () => void | — | Called 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>
);
}