UI kit

StreamingMarkdown

DisplayReact · Next.js

Like 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

PropTypeDefaultDescription
textstringA complete markdown string to reveal token-by-token.
streamAsyncIterable<string> | ReadableStreamA live stream of markdown chunks.
speednumber12Milliseconds per character when revealing text.
cursorbooleantrueShow the blinking cursor while streaming.
onDone() => voidCalled 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>
  );
}