UI kit
StreamingMarkdown
DisplayReact · Next.jsLike StreamingText, but renders markdown as it streams: headings, bullet/numbered lists, bold/italic, inline code, fenced code blocks, and links. Safe by construction — it builds React nodes and never injects raw HTML.
<StreamingMarkdown />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/StreamingMarkdown.tsx) and import it.
Usage
usage.tsx
import { StreamingMarkdown } from "@/components/StreamingMarkdown";
export default function Example() {
return (
<StreamingMarkdown text={answer} />
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | A complete markdown string to reveal token-by-token. |
stream | AsyncIterable<string> | ReadableStream | — | A live stream of markdown chunks. |
speed | number | 12 | Milliseconds per character when revealing text. |
cursor | boolean | true | Show the blinking cursor while streaming. |
onDone | () => void | — | Called when the stream/reveal 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.
StreamingMarkdown.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
interface StreamingMarkdownProps {
/** A complete markdown string to reveal token-by-token. */
text?: string;
/** A live stream of markdown chunks (SSE / chunked fetch body). */
stream?: AsyncIterable<string> | ReadableStream<Uint8Array>;
/** Milliseconds per character when revealing `text`. Default: 12. */
speed?: number;
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();
}
}
// ── Minimal, safe markdown → React (renders nodes, no dangerouslySetInnerHTML) ──
function inline(text: string, keyBase: string): ReactNode[] {
const nodes: ReactNode[] = [];
const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
let last = 0;
let k = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const key = `${keyBase}-${k++}`;
if (m[2] !== undefined) nodes.push(<strong key={key}>{m[2]}</strong>);
else if (m[3] !== undefined) nodes.push(<em key={key}>{m[3]}</em>);
else if (m[4] !== undefined) nodes.push(<code key={key} className="rounded bg-zinc-100 px-1 py-0.5 text-[0.85em] dark:bg-zinc-800">{m[4]}</code>);
else if (m[5] !== undefined) nodes.push(<a key={key} href={m[6]} target="_blank" rel="noreferrer" className="text-blue-600 underline dark:text-blue-400">{m[5]}</a>);
last = m.index + m[0].length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
function renderMarkdown(md: string): ReactNode[] {
const lines = md.split("\n");
const blocks: ReactNode[] = [];
let i = 0;
let key = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim().startsWith("```")) {
const buf: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
buf.push(lines[i]);
i++;
}
i++; // skip closing fence
blocks.push(
<pre key={key++} className="my-2 overflow-x-auto rounded-lg bg-zinc-900 p-3 text-[12.5px] text-zinc-100">
<code>{buf.join("\n")}</code>
</pre>
);
continue;
}
const h = /^(#{1,3})\s+(.*)$/.exec(line);
if (h) {
const lvl = h[1].length;
const cls = lvl === 1 ? "text-lg font-bold" : lvl === 2 ? "text-base font-bold" : "text-sm font-semibold";
blocks.push(<p key={key++} className={`mb-1 mt-3 ${cls}`}>{inline(h[2], `h${key}`)}</p>);
i++;
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
i++;
}
blocks.push(
<ul key={key++} className="my-1.5 list-disc space-y-0.5 pl-5">
{items.map((it, j) => <li key={j}>{inline(it, `ul${key}-${j}`)}</li>)}
</ul>
);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ""));
i++;
}
blocks.push(
<ol key={key++} className="my-1.5 list-decimal space-y-0.5 pl-5">
{items.map((it, j) => <li key={j}>{inline(it, `ol${key}-${j}`)}</li>)}
</ol>
);
continue;
}
if (line.trim() === "") {
i++;
continue;
}
const para: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!/^\s*[-*]\s+/.test(lines[i]) &&
!/^\s*\d+\.\s+/.test(lines[i]) &&
!/^#{1,3}\s/.test(lines[i]) &&
!lines[i].trim().startsWith("```")
) {
para.push(lines[i]);
i++;
}
blocks.push(<p key={key++} className="my-1.5 leading-relaxed">{inline(para.join(" "), `p${key}`)}</p>);
}
return blocks;
}
/**
* StreamingMarkdown — like StreamingText, but renders markdown (headings,
* lists, bold/italic, inline code, code fences, links) as it streams in. Safe
* by construction: it builds React nodes, never injecting raw HTML.
*/
export function StreamingMarkdown({ text, stream, speed = 12, cursor = true, className = "", onDone }: StreamingMarkdownProps) {
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((p) => p + chunk);
}
}
if (!cancelled) {
setDone(true);
onDoneRef.current?.();
}
}
run();
return () => {
cancelled = true;
};
}, [text, stream, speed]);
return (
<div className={`text-[14px] text-zinc-700 dark:text-zinc-200 ${className}`}>
{renderMarkdown(out)}
{cursor && !done && (
<span className="ml-0.5 inline-block w-[2px] animate-pulse bg-current align-text-bottom" style={{ height: "1.05em" }} />
)}
</div>
);
}