Streaming Markdown
AIRenders markdown from a string that grows as it streams, with a caret trailing the last glyph. Re-parses the whole string each render into stable, index-keyed blocks so React updates text in place — earlier content never re-mounts, keeping selection and scroll intact while tokens arrive. Unclosed constructs degrade gracefully (an open ``` is already a code block; a dangling ** stays literal until it closes). Hand-written parser, no markdown dependency; monochrome and reduced-motion aware.
Bloom filters
A Bloom filter is a compact, probabilistic set. It answers one question — “have I possibly seen this key?” — with far less memory than storing the keys.
- Add: hash the key
kways, set those bits to 1 - Test: if any bit is 0 it's definitely absent
- Otherwise it's ~probably~ probably present
function mightContain(bits: Uint8Array, key: string) {
return hashes(key).every((h) => bits[h] === 1);
}False negatives are impossible; false positives rise as the array fills.
Reach for one as a cheap gate in front of an expensive lookup.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* StreamingMarkdown — renders markdown from a string that grows as it streams,
* with a blinking caret trailing the last character.
*
* <StreamingMarkdown text={partial} isStreaming={busy} />
*
* The hard part is incremental rendering without re-mounting. Every render
* re-parses the *whole* string into a stable list of blocks (index-keyed) and
* inline runs; because block types and positions stay put as text appends, React
* reconciles by updating text nodes in place — earlier paragraphs never unmount,
* so scroll position, text selection, and the DOM stay intact while tokens keep
* arriving. Unterminated constructs are handled gracefully: an unclosed ``` is
* already a code block, and a dangling `**` renders literally until it closes
* (so nothing flashes bold and back). The caret is threaded into the *last*
* block's last inline run, so it sits right after the final glyph, inline.
*
* A small hand-written parser (headings, bold/italic/strike, inline + fenced
* code, links, lists, quotes, rules) — no markdown dependency. Monochrome,
* honors `prefers-reduced-motion`.
*/
type Block =
| { type: "p"; text: string }
| { type: "h"; level: number; text: string }
| { type: "code"; lang: string; code: string }
| { type: "ul"; items: string[] }
| { type: "ol"; start: number; items: string[] }
| { type: "quote"; text: string }
| { type: "hr" };
const RE = {
fence: /^```(.*)$/,
blank: /^\s*$/,
hr: /^\s*([-*_])\s*(\1\s*){2,}$/,
heading: /^(#{1,6})\s+(.*)$/,
quote: /^\s*>\s?/,
ul: /^\s*[-*+]\s+/,
ol: /^\s*(\d+)\.\s+(.*)$/,
};
function startsBlock(line: string): boolean {
return (
RE.fence.test(line) ||
RE.hr.test(line) ||
RE.heading.test(line) ||
RE.quote.test(line) ||
RE.ul.test(line) ||
RE.ol.test(line)
);
}
/** Parse the full string into blocks. Cheap enough to run on every token. */
function parseBlocks(src: string): Block[] {
const lines = src.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const fence = RE.fence.exec(line);
if (fence) {
const lang = fence[1].trim();
const code: string[] = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1; // consume closing fence when present
blocks.push({ type: "code", lang, code: code.join("\n") });
continue;
}
if (RE.blank.test(line)) {
i += 1;
continue;
}
if (RE.hr.test(line)) {
blocks.push({ type: "hr" });
i += 1;
continue;
}
const h = RE.heading.exec(line);
if (h) {
blocks.push({ type: "h", level: h[1].length, text: h[2] });
i += 1;
continue;
}
if (RE.quote.test(line)) {
const qs: string[] = [];
while (i < lines.length && RE.quote.test(lines[i])) {
qs.push(lines[i].replace(RE.quote, ""));
i += 1;
}
blocks.push({ type: "quote", text: qs.join("\n") });
continue;
}
if (RE.ul.test(line)) {
const items: string[] = [];
while (i < lines.length && RE.ul.test(lines[i])) {
items.push(lines[i].replace(RE.ul, ""));
i += 1;
}
blocks.push({ type: "ul", items });
continue;
}
if (RE.ol.test(line)) {
const items: string[] = [];
let start: number | null = null;
let m = RE.ol.exec(lines[i]);
while (i < lines.length && m) {
if (start === null) start = parseInt(m[1], 10);
items.push(m[2]);
i += 1;
m = i < lines.length ? RE.ol.exec(lines[i]) : null;
}
blocks.push({ type: "ol", start: start ?? 1, items });
continue;
}
// Paragraph: gather lines until a blank line or a new block starts.
const para: string[] = [];
while (
i < lines.length &&
!RE.blank.test(lines[i]) &&
!startsBlock(lines[i])
) {
para.push(lines[i]);
i += 1;
}
blocks.push({ type: "p", text: para.join("\n") });
}
return blocks;
}
/** Find the end of a single-char emphasis run, ignoring doubled markers. */
function italicEnd(text: string, start: number, ch: string): number {
for (let j = start + 1; j < text.length; j += 1) {
if (text[j] === ch && text[j + 1] !== ch && text[j - 1] !== ch) return j;
}
return -1;
}
/** Parse inline markdown into React nodes. Unclosed markers render literally. */
function parseInline(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
let key = 0;
let i = 0;
const flush = () => {
if (buf) {
out.push(buf);
buf = "";
}
};
while (i < text.length) {
const c = text[i];
const two = text.slice(i, i + 2);
if (c === "`") {
const end = text.indexOf("`", i + 1);
if (end > i) {
flush();
out.push(
<code
key={key++}
className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-[0.85em] text-zinc-950 dark:bg-zinc-800 dark:text-zinc-50"
>
{text.slice(i + 1, end)}
</code>,
);
i = end + 1;
continue;
}
}
if (c === "[") {
const m = /^\[([^\]]*)\]\(([^)\s]+)\)/.exec(text.slice(i));
if (m) {
flush();
out.push(
<a
key={key++}
href={m[2]}
target="_blank"
rel="noreferrer"
className="font-medium text-zinc-950 underline underline-offset-2 transition-opacity hover:opacity-70 dark:text-zinc-50"
>
{parseInline(m[1])}
</a>,
);
i += m[0].length;
continue;
}
}
if (two === "**" || two === "__") {
const end = text.indexOf(two, i + 2);
if (end > i + 1) {
flush();
out.push(
<strong
key={key++}
className="font-semibold text-zinc-950 dark:text-zinc-50"
>
{parseInline(text.slice(i + 2, end))}
</strong>,
);
i = end + 2;
continue;
}
}
if (two === "~~") {
const end = text.indexOf("~~", i + 2);
if (end > i + 1) {
flush();
out.push(
<del key={key++} className="opacity-60">
{parseInline(text.slice(i + 2, end))}
</del>,
);
i = end + 2;
continue;
}
}
if (c === "*" || c === "_") {
const end = italicEnd(text, i, c);
if (end > i + 1) {
flush();
out.push(
<em key={key++} className="italic">
{parseInline(text.slice(i + 1, end))}
</em>,
);
i = end + 1;
continue;
}
}
buf += c;
i += 1;
}
flush();
return out;
}
function Caret() {
return (
<span
aria-hidden="true"
className="ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.15em] rounded-[1px] bg-zinc-950 align-text-bottom motion-safe:animate-pulse dark:bg-zinc-50"
/>
);
}
const HEADING_CLASS: Record<number, string> = {
1: "text-xl font-semibold tracking-tight text-zinc-950 dark:text-zinc-50",
2: "text-lg font-semibold tracking-tight text-zinc-950 dark:text-zinc-50",
3: "text-base font-semibold text-zinc-950 dark:text-zinc-50",
4: "text-sm font-semibold text-zinc-950 dark:text-zinc-50",
5: "text-sm font-semibold text-zinc-950 dark:text-zinc-50",
6: "text-sm font-semibold text-zinc-500 dark:text-zinc-400",
};
function renderBlock(
block: Block,
key: number,
caret: React.ReactNode,
): React.ReactNode {
switch (block.type) {
case "h":
return React.createElement(
`h${Math.min(Math.max(block.level, 1), 6)}`,
{ key, className: HEADING_CLASS[block.level] ?? HEADING_CLASS[6] },
parseInline(block.text),
caret,
);
case "code":
return (
<pre
key={key}
className="overflow-x-auto rounded-xl border border-zinc-200 bg-zinc-100 p-3 font-mono text-xs leading-relaxed text-zinc-950 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-50"
>
<code>
{block.code || ""}
{caret}
</code>
</pre>
);
case "ul":
return (
<ul
key={key}
className="list-disc space-y-1 pl-5 marker:text-zinc-200 dark:marker:text-zinc-800"
>
{block.items.map((it, idx) => (
<li key={idx}>
{parseInline(it)}
{idx === block.items.length - 1 ? caret : null}
</li>
))}
</ul>
);
case "ol":
return (
<ol
key={key}
start={block.start}
className="list-decimal space-y-1 pl-5 marker:text-zinc-500 dark:marker:text-zinc-400"
>
{block.items.map((it, idx) => (
<li key={idx}>
{parseInline(it)}
{idx === block.items.length - 1 ? caret : null}
</li>
))}
</ol>
);
case "quote":
return (
<blockquote
key={key}
className="border-l-2 border-zinc-200 pl-3 text-zinc-500 italic dark:border-zinc-800 dark:text-zinc-400"
>
{parseInline(block.text)}
{caret}
</blockquote>
);
case "hr":
return (
<React.Fragment key={key}>
<hr className="border-zinc-200 dark:border-zinc-800" />
{caret}
</React.Fragment>
);
default:
return (
<p key={key} className="text-zinc-500 dark:text-zinc-400">
{parseInline(block.text)}
{caret}
</p>
);
}
}
export interface StreamingMarkdownProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** The markdown source. Grow it over time to stream. */
text: string;
/** Show the trailing caret while tokens are still arriving. */
isStreaming?: boolean;
}
export function StreamingMarkdown({
text,
isStreaming = false,
className,
...props
}: StreamingMarkdownProps) {
const blocks = React.useMemo(() => parseBlocks(text), [text]);
const last = blocks.length - 1;
return (
<div
data-slot="streaming-markdown"
className={cn(
"space-y-3 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400",
className,
)}
{...props}
>
{blocks.map((block, i) =>
renderBlock(block, i, isStreaming && i === last ? <Caret /> : null),
)}
{isStreaming && blocks.length === 0 ? <Caret /> : null}
</div>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/streaming-markdown.json1. Install dependencies
npm install clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* StreamingMarkdown — renders markdown from a string that grows as it streams,
* with a blinking caret trailing the last character.
*
* <StreamingMarkdown text={partial} isStreaming={busy} />
*
* The hard part is incremental rendering without re-mounting. Every render
* re-parses the *whole* string into a stable list of blocks (index-keyed) and
* inline runs; because block types and positions stay put as text appends, React
* reconciles by updating text nodes in place — earlier paragraphs never unmount,
* so scroll position, text selection, and the DOM stay intact while tokens keep
* arriving. Unterminated constructs are handled gracefully: an unclosed ``` is
* already a code block, and a dangling `**` renders literally until it closes
* (so nothing flashes bold and back). The caret is threaded into the *last*
* block's last inline run, so it sits right after the final glyph, inline.
*
* A small hand-written parser (headings, bold/italic/strike, inline + fenced
* code, links, lists, quotes, rules) — no markdown dependency. Monochrome,
* honors `prefers-reduced-motion`.
*/
type Block =
| { type: "p"; text: string }
| { type: "h"; level: number; text: string }
| { type: "code"; lang: string; code: string }
| { type: "ul"; items: string[] }
| { type: "ol"; start: number; items: string[] }
| { type: "quote"; text: string }
| { type: "hr" };
const RE = {
fence: /^```(.*)$/,
blank: /^\s*$/,
hr: /^\s*([-*_])\s*(\1\s*){2,}$/,
heading: /^(#{1,6})\s+(.*)$/,
quote: /^\s*>\s?/,
ul: /^\s*[-*+]\s+/,
ol: /^\s*(\d+)\.\s+(.*)$/,
};
function startsBlock(line: string): boolean {
return (
RE.fence.test(line) ||
RE.hr.test(line) ||
RE.heading.test(line) ||
RE.quote.test(line) ||
RE.ul.test(line) ||
RE.ol.test(line)
);
}
/** Parse the full string into blocks. Cheap enough to run on every token. */
function parseBlocks(src: string): Block[] {
const lines = src.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const fence = RE.fence.exec(line);
if (fence) {
const lang = fence[1].trim();
const code: string[] = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1; // consume closing fence when present
blocks.push({ type: "code", lang, code: code.join("\n") });
continue;
}
if (RE.blank.test(line)) {
i += 1;
continue;
}
if (RE.hr.test(line)) {
blocks.push({ type: "hr" });
i += 1;
continue;
}
const h = RE.heading.exec(line);
if (h) {
blocks.push({ type: "h", level: h[1].length, text: h[2] });
i += 1;
continue;
}
if (RE.quote.test(line)) {
const qs: string[] = [];
while (i < lines.length && RE.quote.test(lines[i])) {
qs.push(lines[i].replace(RE.quote, ""));
i += 1;
}
blocks.push({ type: "quote", text: qs.join("\n") });
continue;
}
if (RE.ul.test(line)) {
const items: string[] = [];
while (i < lines.length && RE.ul.test(lines[i])) {
items.push(lines[i].replace(RE.ul, ""));
i += 1;
}
blocks.push({ type: "ul", items });
continue;
}
if (RE.ol.test(line)) {
const items: string[] = [];
let start: number | null = null;
let m = RE.ol.exec(lines[i]);
while (i < lines.length && m) {
if (start === null) start = parseInt(m[1], 10);
items.push(m[2]);
i += 1;
m = i < lines.length ? RE.ol.exec(lines[i]) : null;
}
blocks.push({ type: "ol", start: start ?? 1, items });
continue;
}
// Paragraph: gather lines until a blank line or a new block starts.
const para: string[] = [];
while (
i < lines.length &&
!RE.blank.test(lines[i]) &&
!startsBlock(lines[i])
) {
para.push(lines[i]);
i += 1;
}
blocks.push({ type: "p", text: para.join("\n") });
}
return blocks;
}
/** Find the end of a single-char emphasis run, ignoring doubled markers. */
function italicEnd(text: string, start: number, ch: string): number {
for (let j = start + 1; j < text.length; j += 1) {
if (text[j] === ch && text[j + 1] !== ch && text[j - 1] !== ch) return j;
}
return -1;
}
/** Parse inline markdown into React nodes. Unclosed markers render literally. */
function parseInline(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let buf = "";
let key = 0;
let i = 0;
const flush = () => {
if (buf) {
out.push(buf);
buf = "";
}
};
while (i < text.length) {
const c = text[i];
const two = text.slice(i, i + 2);
if (c === "`") {
const end = text.indexOf("`", i + 1);
if (end > i) {
flush();
out.push(
<code
key={key++}
className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-[0.85em] text-zinc-950 dark:bg-zinc-800 dark:text-zinc-50"
>
{text.slice(i + 1, end)}
</code>,
);
i = end + 1;
continue;
}
}
if (c === "[") {
const m = /^\[([^\]]*)\]\(([^)\s]+)\)/.exec(text.slice(i));
if (m) {
flush();
out.push(
<a
key={key++}
href={m[2]}
target="_blank"
rel="noreferrer"
className="font-medium text-zinc-950 underline underline-offset-2 transition-opacity hover:opacity-70 dark:text-zinc-50"
>
{parseInline(m[1])}
</a>,
);
i += m[0].length;
continue;
}
}
if (two === "**" || two === "__") {
const end = text.indexOf(two, i + 2);
if (end > i + 1) {
flush();
out.push(
<strong
key={key++}
className="font-semibold text-zinc-950 dark:text-zinc-50"
>
{parseInline(text.slice(i + 2, end))}
</strong>,
);
i = end + 2;
continue;
}
}
if (two === "~~") {
const end = text.indexOf("~~", i + 2);
if (end > i + 1) {
flush();
out.push(
<del key={key++} className="opacity-60">
{parseInline(text.slice(i + 2, end))}
</del>,
);
i = end + 2;
continue;
}
}
if (c === "*" || c === "_") {
const end = italicEnd(text, i, c);
if (end > i + 1) {
flush();
out.push(
<em key={key++} className="italic">
{parseInline(text.slice(i + 1, end))}
</em>,
);
i = end + 1;
continue;
}
}
buf += c;
i += 1;
}
flush();
return out;
}
function Caret() {
return (
<span
aria-hidden="true"
className="ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.15em] rounded-[1px] bg-zinc-950 align-text-bottom motion-safe:animate-pulse dark:bg-zinc-50"
/>
);
}
const HEADING_CLASS: Record<number, string> = {
1: "text-xl font-semibold tracking-tight text-zinc-950 dark:text-zinc-50",
2: "text-lg font-semibold tracking-tight text-zinc-950 dark:text-zinc-50",
3: "text-base font-semibold text-zinc-950 dark:text-zinc-50",
4: "text-sm font-semibold text-zinc-950 dark:text-zinc-50",
5: "text-sm font-semibold text-zinc-950 dark:text-zinc-50",
6: "text-sm font-semibold text-zinc-500 dark:text-zinc-400",
};
function renderBlock(
block: Block,
key: number,
caret: React.ReactNode,
): React.ReactNode {
switch (block.type) {
case "h":
return React.createElement(
`h${Math.min(Math.max(block.level, 1), 6)}`,
{ key, className: HEADING_CLASS[block.level] ?? HEADING_CLASS[6] },
parseInline(block.text),
caret,
);
case "code":
return (
<pre
key={key}
className="overflow-x-auto rounded-xl border border-zinc-200 bg-zinc-100 p-3 font-mono text-xs leading-relaxed text-zinc-950 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-50"
>
<code>
{block.code || ""}
{caret}
</code>
</pre>
);
case "ul":
return (
<ul
key={key}
className="list-disc space-y-1 pl-5 marker:text-zinc-200 dark:marker:text-zinc-800"
>
{block.items.map((it, idx) => (
<li key={idx}>
{parseInline(it)}
{idx === block.items.length - 1 ? caret : null}
</li>
))}
</ul>
);
case "ol":
return (
<ol
key={key}
start={block.start}
className="list-decimal space-y-1 pl-5 marker:text-zinc-500 dark:marker:text-zinc-400"
>
{block.items.map((it, idx) => (
<li key={idx}>
{parseInline(it)}
{idx === block.items.length - 1 ? caret : null}
</li>
))}
</ol>
);
case "quote":
return (
<blockquote
key={key}
className="border-l-2 border-zinc-200 pl-3 text-zinc-500 italic dark:border-zinc-800 dark:text-zinc-400"
>
{parseInline(block.text)}
{caret}
</blockquote>
);
case "hr":
return (
<React.Fragment key={key}>
<hr className="border-zinc-200 dark:border-zinc-800" />
{caret}
</React.Fragment>
);
default:
return (
<p key={key} className="text-zinc-500 dark:text-zinc-400">
{parseInline(block.text)}
{caret}
</p>
);
}
}
export interface StreamingMarkdownProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** The markdown source. Grow it over time to stream. */
text: string;
/** Show the trailing caret while tokens are still arriving. */
isStreaming?: boolean;
}
export function StreamingMarkdown({
text,
isStreaming = false,
className,
...props
}: StreamingMarkdownProps) {
const blocks = React.useMemo(() => parseBlocks(text), [text]);
const last = blocks.length - 1;
return (
<div
data-slot="streaming-markdown"
className={cn(
"space-y-3 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400",
className,
)}
{...props}
>
{blocks.map((block, i) =>
renderBlock(block, i, isStreaming && i === last ? <Caret /> : null),
)}
{isStreaming && blocks.length === 0 ? <Caret /> : null}
</div>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | The markdown source. Grow it over time to stream. |
isStreaming | boolean | false | Show the blinking caret after the last glyph while tokens are still arriving. |
...props | React.ComponentProps<"div"> | — | All native div attributes are forwarded. |