@tailmark/react
v0.1.1
Published
React components for rendering streaming Markdown.
Readme
@tailmark/react
A React 18+ renderer for growing Markdown that memoizes top-level blocks and forwards the React Markdown extension surface.
bun add @tailmark/react react react-markdownQuickstart
import { StreamingMarkdown } from "@tailmark/react";
export function Message({ text, pending }: { text: string; pending: boolean }) {
return (
<StreamingMarkdown isStreaming={pending}>
{text}
</StreamingMarkdown>
);
}For raw SSE token state, useCoalescedValue(text) limits visible updates to one per animation frame and continues draining every 500ms in a hidden tab.
Styling
Tailmark is headless by default. The built-in renderer emits semantic elements and stable data-* hooks. Import the optional defaults once:
import "@tailmark/react/styles.css";Common variables:
| Variable | Controls |
| --- | --- |
| --tm-space-block, --tm-space-heading, --tm-space-list | Prose rhythm |
| --tm-code-border, --tm-code-surface, --tm-code-radius | Code-block container |
| --tm-code-font, --tm-code-font-size | Block and inline code typography |
| --tm-code-padding-inline, --tm-code-padding-block | Code chrome spacing |
| --tm-table-border, --tm-table-header-surface | Table rules and header |
| --tm-reveal-duration, --tm-reveal-easing | New-word reveal |
| --tm-caret-color, --tm-caret-duration | Streaming caret |
@tailmark/shiki emits both --shiki-light and --shiki-dark. Choose either a class switch:
[data-tm-code-block] .shiki,
[data-tm-code-block] .shiki span {
color: var(--shiki-light);
}
.dark [data-tm-code-block] .shiki,
.dark [data-tm-code-block] .shiki span {
color: var(--shiki-dark);
}Or follow the operating-system preference:
[data-tm-code-block] .shiki,
[data-tm-code-block] .shiki span {
color: var(--shiki-light);
}
@media (prefers-color-scheme: dark) {
[data-tm-code-block] .shiki,
[data-tm-code-block] .shiki span {
color: var(--shiki-dark);
}
}Reveal and caret
Both effects are opt-in and apply only to the unstable streaming tail:
<StreamingMarkdown isStreaming={pending} reveal caret>
{text}
</StreamingMarkdown>Pass a React node to caret for a custom glyph. The optional stylesheet disables both animations under prefers-reduced-motion: reduce.
Extension points
Extend the pinned sanitize schema without moving sanitization ahead of user plugins:
<StreamingMarkdown
isStreaming={pending}
sanitizeSchema={(schema) => ({
...schema,
attributes: {
...schema.attributes,
div: [...(schema.attributes?.div ?? []), ["className", "callout"]],
},
})}
>
{text}
</StreamingMarkdown>Keep React Markdown's URL safety while allowing an application route, and override elements through components:
import { defaultUrlTransform } from "react-markdown";
<StreamingMarkdown
isStreaming={pending}
urlTransform={(url) =>
url.startsWith("/app/") ? url : defaultUrlTransform(url)
}
components={{
a: ({ children, href }) => <a href={href} rel="noreferrer">{children}</a>,
}}
>
{text}
</StreamingMarkdown>The repairs, remarkPlugins, rehypePlugins, allowedElements, disallowedElements, skipHtml, allowElement, unwrapDisallowed, and remarkRehypeOptions props are also supported.
Migration
From React Markdown, rename the component and add isStreaming:
// Before: <Markdown components={components}>{text}</Markdown>
<StreamingMarkdown isStreaming={pending} components={components}>
{text}
</StreamingMarkdown>Migrating from Streamdown v2
| Streamdown | Tailmark |
| --- | --- |
| Markdown as children | Markdown as children, plus the required isStreaming state |
| mode and isAnimating | isStreaming; use false for settled or static content |
| parseIncompleteMarkdown and remend | Built-in windowed repair; add repairs for full-string application extensions |
| animated and caret | Opt-in reveal and caret; these are Tailmark effects, not animation-setting aliases |
| plugins.code or shikiTheme | highlighter={createShikiHighlighter({ themes, langs })} from @tailmark/shiki |
| controls | Override components for application-owned controls and rendering |
| plugins.math, plugins.mermaid, and plugins.cjk | No built-in equivalent in Tailmark v1; compose through React Markdown extension points where practical |
This table was checked against Streamdown 2.5.0, which receives Markdown through children and exposes code, math, Mermaid, and CJK through separately installed official plugin packages. Tailmark v1 intentionally ships a narrower surface and does not bundle those capabilities.
Custom highlighters
Any synchronous adapter that satisfies StreamingHighlighter inherits Tailmark's throttle, settle, and cache-write policy. This minimal adapter renders code without tokenization while demonstrating the full contract:
import { createElement, type ReactNode } from "react";
import type { HighlightOutput, StreamingHighlighter } from "@tailmark/react";
const cache = new Map<string, ReactNode>();
const key = (code: string, lang: string) => `${lang}\0${code}`;
export const plainHighlighter: StreamingHighlighter = {
highlight(code, lang): HighlightOutput {
return {
node: createElement("pre", { "data-language": lang }, code),
weight: code.length,
};
},
getCached(code, lang) {
return cache.get(key(code, lang)) ?? null;
},
setCached(code, lang, output) {
cache.set(key(code, lang), output.node);
},
subscribe() {
return () => {};
},
};