Browse all components
Streaming Markdown UI
Markdown that renders correctly while it is still arriving — bold, code spans, links and tables settle without ever flashing raw syntax or reflowing.
Preview
The right answer depends on what you are optim
Presets
Props
Agent promptClaude Code · Cursor · any agent
Add the Streaming Markdown UI component from the Scrim UI registry to this project and use it in the configuration described below.
Streaming Markdown UI — Markdown that renders correctly while it is still arriving — bold, code spans, links and tables settle without ever flashing raw syntax or reflowing.
## 1. Install
```bash
npx shadcn@latest add @scrimui/streaming-markdown
```
This writes a single file to `components/ui/streaming-markdown.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/streaming-markdown to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { StreamingMarkdown } from "@/components/ui/streaming-markdown";
<StreamingMarkdown
text="The right answer depends on **what you are optim"
streaming
/>
```
## 3. The configuration that matters
This is the component's "Unclosed bold" state — `**what you are optim` — already bold, asterisks hidden. Closing it changes nothing.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
Reference: https://scrimui.dev/components/streaming-markdownFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/streaming-markdownComponent source
React + Tailwind component using shadcn/ui (button). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button"use client";
import { Button } from "@/components/ui/button";
import * as React from "react";
/**
* Markdown that renders correctly *while it is still arriving*.
*
* The received wisdom is that you cannot do this — render plain text while
* streaming, then swap in rendered markdown when the turn ends. That advice
* exists because the naive approach genuinely looks broken: `**bol` renders
* as two literal asterisks, then snaps to bold when the closing pair lands.
* A paragraph of prose flashes syntax half a dozen times on its way in, and
* a table sits as a row of raw pipes until its separator arrives.
*
* Swapping at the end trades one flaw for a worse one: the entire message
* reflows at the exact moment the reader is furthest down it.
*
* Three ideas make streaming markdown work.
*
* 1. COMPLETENESS IS POSITIONAL. Only the tail of the text can be
* incomplete. An unmatched `**` in the middle of the text is not a bold
* span waiting to close — more text arrived after it, so it is literally
* two asterisks. Everything before the tail is parsed strictly, exactly
* as a finished document would be.
*
* 2. SPECULATIVE CLOSING. In the tail, an unclosed construct is closed
* virtually and rendered in its final form: `**bol` renders as bold "bol"
* with the asterisks hidden. When `**bold**` completes, nothing changes —
* it was already bold. No flash, and more importantly no reflow.
*
* 3. WHEN AMBIGUOUS, HOLD. A lone `#`, a single backtick, a table row whose
* next line has not arrived — these could become several different
* things. Rendering nothing for one token is imperceptible. Rendering the
* wrong thing and correcting it is the flicker we came to remove. This is
* the rule people skip, because holding output feels like doing less.
*
* Speculation is only valid while text is still coming, which is what
* `streaming` controls. Once the turn is done, unmatched delimiters are
* literal characters and get rendered as such — the same text parsed by the
* same rules any other markdown renderer would use.
*/
export type StreamingMarkdownProps = {
/** The markdown so far. Safe to pass a value that ends mid-token. */
text: string;
/**
* Whether more text is still coming. Drives speculation *and* correctness:
* with `false`, a trailing `**` is two asterisks, because it is.
*/
streaming?: boolean;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Inline — speculative closing */
/* ------------------------------------------------------------------ */
type Unclosed = { index: number; kind: "code" | "strong" | "em" | "link" };
/**
* The first delimiter in `text` that is opened and never closed.
*
* Scans left to right and skips over *matched* pairs rather than searching
* from the end, so `a *b* c **d` finds the `**` and not the earlier `*`
* that already closed.
*/
function findUnclosed(text: string): Unclosed | null {
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === "`") {
const close = text.indexOf("`", i + 1);
if (close === -1) return { index: i, kind: "code" };
i = close + 1;
continue;
}
if (ch === "*" && text[i + 1] === "*") {
const close = text.indexOf("**", i + 2);
if (close === -1) return { index: i, kind: "strong" };
i = close + 2;
continue;
}
if (ch === "*") {
const close = text.indexOf("*", i + 1);
if (close === -1) return { index: i, kind: "em" };
i = close + 1;
continue;
}
if (ch === "[") {
/* A link is only settled once its closing paren lands — `[a](htt` is
still in flight even though the bracket pair is complete. */
const close = text.indexOf(")", i);
if (close === -1) return { index: i, kind: "link" };
i = close + 1;
continue;
}
i++;
}
return null;
}
const LINK_CLASS = "text-(--foreground) underline underline-offset-2";
function renderInline(line: string, speculate: boolean, keyBase: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const tokenRe = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]*\))/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = tokenRe.exec(line)) !== null) {
if (m.index > last) {
nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{line.slice(last, m.index)}</React.Fragment>);
}
const tok = m[0];
const k = `${keyBase}-k${i++}`;
if (tok.startsWith("`")) nodes.push(<code key={k}>{tok.slice(1, -1)}</code>);
else if (tok.startsWith("**")) nodes.push(<strong key={k}>{tok.slice(2, -2)}</strong>);
else if (tok.startsWith("[")) {
const md = tok.match(/^\[([^\]]+)\]\(([^)]*)\)$/);
nodes.push(
md && md[2] ? (
<a key={k} href={md[2]} target="_blank" rel="noreferrer noopener" className={LINK_CLASS}>
{md[1]}
</a>
) : (
/* Bracket pair closed, href still empty: style it as a link now so
that filling the href in later changes nothing visible. */
<span key={k} className={LINK_CLASS}>
{md ? md[1] : tok}
</span>
),
);
} else nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);
last = m.index + tok.length;
}
const rest = line.slice(last);
if (!rest) return nodes;
const unclosed = speculate ? findUnclosed(rest) : null;
if (!unclosed) {
nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{rest}</React.Fragment>);
return nodes;
}
/* Text before the opener is ordinary. */
if (unclosed.index > 0) {
nodes.push(<React.Fragment key={`${keyBase}-t${i++}`}>{rest.slice(0, unclosed.index)}</React.Fragment>);
}
const k = `${keyBase}-spec`;
if (unclosed.kind === "code") {
nodes.push(<code key={k}>{rest.slice(unclosed.index + 1)}</code>);
} else if (unclosed.kind === "strong") {
nodes.push(<strong key={k}>{rest.slice(unclosed.index + 2)}</strong>);
} else if (unclosed.kind === "em") {
nodes.push(<em key={k}>{rest.slice(unclosed.index + 1)}</em>);
} else {
/* `[label` or `[label](ur` — render the label, already link-styled. The
href is the only thing still missing and it is not visible anyway, so
when it lands the text does not move. */
const body = rest.slice(unclosed.index + 1);
const bracket = body.indexOf("]");
nodes.push(
<span key={k} className={LINK_CLASS}>
{bracket === -1 ? body : body.slice(0, bracket)}
</span>,
);
}
return nodes;
}
/* ------------------------------------------------------------------ */
/* Pieces */
/* ------------------------------------------------------------------ */
function CodeBlock({
lang,
code,
caret,
}: {
lang: string;
code: string;
caret?: React.ReactNode;
}) {
const [copied, setCopied] = React.useState(false);
return (
<div className="my-3 overflow-hidden rounded-lg border border-border">
<div className="flex items-center justify-between border-b border-border bg-muted px-3 py-1.5">
<span className="font-mono text-xs text-(--muted-foreground)">{lang || "text"}</span>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
void navigator.clipboard?.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
className="text-xs text-(--muted-foreground) transition-colors hover:text-(--foreground)"
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto bg-muted px-3 py-3 text-sm leading-5 text-muted-foreground">
<code>
{code}
{caret}
</code>
</pre>
</div>
);
}
function isTableSeparator(line: string): boolean {
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
}
function cells(line: string): string[] {
return line
.split("|")
.map((c) => c.trim())
.filter((c, idx, arr) => !(c === "" && (idx === 0 || idx === arr.length - 1)));
}
/* ------------------------------------------------------------------ */
/* Hold — the ambiguous tail */
/* ------------------------------------------------------------------ */
/**
* Should the final line be withheld for now?
*
* Every case here is a fragment that could still become more than one thing.
* Holding costs one token of latency, which no one perceives; guessing costs
* a visible correction, which everyone does.
*/
function shouldHoldLastLine(lines: string[]): boolean {
const line = lines[lines.length - 1];
if (line === undefined) return false;
const t = line.trim();
if (t === "") return false;
/* A run of one or two backticks is either inline code opening or a fence
marker still being typed. Those render completely differently. */
if (/^`{1,2}$/.test(t)) return true;
/* Markers with no content yet: `#`, `-`, `>`, `1.` */
if (/^#{1,6}$/.test(t)) return true;
if (/^[-*+]$/.test(t)) return true;
if (/^>$/.test(t)) return true;
if (/^\d+\.$/.test(t)) return true;
/* A pipe row is only a table once its separator arrives. Until then it
could equally be prose containing a pipe. Rendering it as a paragraph
and promoting it to a table later is the single ugliest reflow in
streamed markdown, because the whole block changes shape. */
if (t.includes("|")) {
const prev = lines[lines.length - 2]?.trim() ?? "";
const established = prev.includes("|") || isTableSeparator(prev);
if (!established) return true;
}
/* A separator on its own, with the header above it, is safe to hold too —
the table renders the moment there is something to put under it. */
if (isTableSeparator(t)) return true;
return false;
}
/* ------------------------------------------------------------------ */
/* Blocks */
/* ------------------------------------------------------------------ */
function parseBlocks(
text: string,
speculate: boolean,
keyBase: string,
caret = false,
): React.ReactNode[] {
let lines = text.split("\n");
if (speculate && shouldHoldLastLine(lines)) lines = lines.slice(0, -1);
const blocks: React.ReactNode[] = [];
let i = 0;
let key = 0;
/* The caret has to sit *inside* the last block, not after it. Rendered as a
sibling of the blocks it is a span following a <p>, which is block-level,
so it drops onto a line of its own and reads as a stray character. The
branches below already know which run of text is last — that is the same
condition that decides whether to speculate — so the caret rides along
with it. If the last block is a table or a code block there is no
sensible inline slot, and it falls through to the trailing push. */
let caretPlaced = false;
const tip = (last: boolean) => {
if (!caret || !last || caretPlaced) return null;
caretPlaced = true;
return <Caret />;
};
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
const k = `${keyBase}-b${key++}`;
if (trimmed === "") {
i++;
continue;
}
/* Fenced code. An unclosed fence is rendered as a code block straight
away rather than as a paragraph of backticks — the language label is
already known, and the block only grows downward from here, so nothing
above it moves. */
if (trimmed.startsWith("```")) {
const lang = trimmed.replace(/^```/, "").trim();
let j = i + 1;
const code: string[] = [];
while (j < lines.length && !lines[j].trimStart().startsWith("```")) {
code.push(lines[j]);
j++;
}
blocks.push(
<CodeBlock key={k} lang={lang} code={code.join("\n")} caret={tip(j >= lines.length)} />,
);
i = j + 1;
continue;
}
/* Heading */
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
const sizes = ["text-xl", "text-lg", "text-base", "text-sm", "text-sm", "text-sm"];
blocks.push(
<p key={k} className={`mt-4 mb-2 font-semibold first:mt-0 ${sizes[level - 1]}`}>
{renderInline(heading[2], speculate, k)}
{tip(i === lines.length - 1)}
</p>,
);
i++;
continue;
}
/* Table */
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const header = cells(line);
let j = i + 2;
const body: string[][] = [];
while (j < lines.length && lines[j].includes("|") && !isTableSeparator(lines[j])) {
body.push(cells(lines[j]));
j++;
}
blocks.push(
<div key={k} className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-sm leading-5">
<thead>
<tr>
{header.map((h, hk) => (
<th key={hk} className="border-b border-border px-2 py-1.5 text-left font-medium">
{renderInline(h, false, `${k}-h${hk}`)}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, rk) => (
<tr key={rk}>
{/* Padded to the header's width: a row that is still
arriving has fewer cells, and letting the column count
change would make the whole table jump. */}
{header.map((_, ck) => (
<td key={ck} className="border-b border-border px-2 py-1.5 align-top">
{renderInline(row[ck] ?? "", false, `${k}-r${rk}c${ck}`)}
{/* Last *filled* cell, not the last column: a row that
is still arriving is padded out with empty cells,
and the caret belongs where the text stopped. */}
{tip(
j >= lines.length &&
rk === body.length - 1 &&
ck === Math.min(row.length, header.length) - 1,
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
);
i = j;
continue;
}
/* Lists */
const bullet = trimmed.match(/^[-*+]\s+(.*)$/);
const ordered = trimmed.match(/^(\d+)\.\s+(.*)$/);
if (bullet || ordered) {
const items: string[] = [];
const isOrdered = Boolean(ordered);
let j = i;
while (j < lines.length) {
const t = lines[j].trim();
const b = t.match(/^[-*+]\s+(.*)$/);
const o = t.match(/^(\d+)\.\s+(.*)$/);
if (isOrdered && o) items.push(o[2]);
else if (!isOrdered && b) items.push(b[1]);
else break;
j++;
}
const ListTag = isOrdered ? "ol" : "ul";
blocks.push(
<ListTag key={k} className={`my-2 space-y-1 pl-5 ${isOrdered ? "list-decimal" : "list-disc"}`}>
{items.map((item, ik) => (
<li key={ik}>
{renderInline(item, speculate && j === lines.length && ik === items.length - 1, `${k}-i${ik}`)}
{tip(j >= lines.length && ik === items.length - 1)}
</li>
))}
</ListTag>,
);
i = j;
continue;
}
/* Blockquote */
const quote = trimmed.match(/^>\s?(.*)$/);
if (quote) {
blocks.push(
<blockquote key={k} className="my-3 border-l-2 border-(--border) pl-3 text-(--muted-foreground)">
{renderInline(quote[1], speculate && i === lines.length - 1, k)}
{tip(i === lines.length - 1)}
</blockquote>,
);
i++;
continue;
}
/* Paragraph — consecutive non-blank lines that start no other block. */
const para: string[] = [];
let j = i;
while (
j < lines.length &&
lines[j].trim() !== "" &&
!lines[j].trimStart().startsWith("```") &&
!/^#{1,6}\s/.test(lines[j].trim()) &&
!/^[-*+]\s/.test(lines[j].trim()) &&
!/^\d+\.\s/.test(lines[j].trim()) &&
!/^>\s?/.test(lines[j].trim())
) {
para.push(lines[j]);
j++;
}
blocks.push(
<p key={k} className="my-2 leading-6 first:mt-0 last:mb-0">
{para.map((l, lk) => (
<React.Fragment key={lk}>
{lk > 0 && " "}
{renderInline(l, speculate && j === lines.length && lk === para.length - 1, `${k}-l${lk}`)}
{tip(j >= lines.length && lk === para.length - 1)}
</React.Fragment>
))}
</p>,
);
i = j;
}
if (caret && !caretPlaced) blocks.push(<Caret key={`${keyBase}-caret`} />);
return blocks;
}
/**
* The "still typing" tip. `aria-hidden` because a screen reader announcing a
* blinking rectangle at the end of every partial sentence is noise, not
* information — the streamed text itself is the signal.
*/
function Caret() {
return (
<span
aria-hidden
className="ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] animate-pulse bg-(--foreground) align-baseline"
/>
);
}
/* ------------------------------------------------------------------ */
/* Stable / tail split */
/* ------------------------------------------------------------------ */
/**
* Everything before the last block boundary can never change again, so it is
* parsed once and memoised. Without this, every token re-parses the whole
* message: quadratic over a stream, and very visible by the time an answer
* is a few thousand tokens long.
*
* The boundary has to sit outside a code fence. A blank line inside one is
* not a block boundary, and splitting there would parse half a fence as
* prose — hence the parity check rather than a plain `lastIndexOf`.
*/
function splitStable(text: string): { stable: string; tail: string } {
let from = text.length;
for (;;) {
const idx = text.lastIndexOf("\n\n", from);
if (idx === -1) return { stable: "", tail: text };
const before = text.slice(0, idx);
const fences = before.match(/^```/gm)?.length ?? 0;
if (fences % 2 === 0) return { stable: before, tail: text.slice(idx) };
from = idx - 1;
if (from < 0) return { stable: "", tail: text };
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function StreamingMarkdown({ text, streaming = false, className }: StreamingMarkdownProps) {
const { stable, tail } = React.useMemo(() => splitStable(text), [text]);
/* `stable` only changes when a block boundary is crossed — a few times per
message, not a few hundred. This memo is the whole reason long answers
stay smooth. */
const stableBlocks = React.useMemo(() => parseBlocks(stable, false, "s"), [stable]);
const tailBlocks = parseBlocks(tail, streaming, "t", streaming);
return (
<div className={`text-sm leading-6 ${className ?? ""}`}>
{stableBlocks}
{tailBlocks}
</div>
);
}
When to use it
- Pass `streaming` straight from the SDK's status — it drives correctness, not just a caret. Finished text must be parsed strictly, because an unmatched `**` in a completed message is two literal asterisks.
- Render this instead of swapping renderers at the end of a turn. Swapping reflows the whole message at the moment the reader is furthest down it.
- Keep the message container a fixed width. Speculation removes reflow from inside the text; a container that resizes with content puts it back.
- Use it for the assistant turn only. User messages are complete the moment they exist and have nothing to speculate about.
What breaks in production
- Leaving `streaming` on after the turn ends — trailing asterisks or backticks in the final text then render as formatting the author never wrote.
- Re-parsing the whole message on every token. Blocks above the last boundary cannot change; parsing them once is the difference between smooth and stuttering at a few thousand tokens.
- Rendering an ambiguous fragment rather than holding it. A lone `#` or a pipe row with no separator yet can still become several different things, and correcting it on screen is the flicker you were trying to remove.
- Letting a partly-arrived table row set the column count — every subsequent cell then shifts sideways as the row fills.
Related Components
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.
The user's chat bubble — right-aligned, with hover actions to copy, edit the prompt, or regenerate the answer.
The action row under an AI reply — copy, regenerate, share and thumbs up/down feedback, revealed on hover.
What to show when a generation fails — a plain-English reason, a retry button, and a countdown for rate limits.