A simple, headless React streaming markdown renderer.
pnpm add @opticlm/streamup react react-domimport { Streamup } from '@opticlm/streamup'
// Basic
<Streamup>{'# Hello **world**'}</Streamup>
// Streaming (incomplete markdown)
<Streamup streaming>{'**bold text without clos'}</Streamup>
// Custom components
<Streamup
components={{
h1: ({ children }) => <h1 className="text-4xl font-bold">{children}</h1>,
p: ({ children }) => <p className="my-4 leading-relaxed">{children}</p>,
code: ({ children, className, ...props }) => {
if ('data-block' in props) {
const { 'data-language': language } = props
return (
<pre className="bg-zinc-900 p-4 rounded">
<code className={className}>{children}</code>
</pre>
)
}
return <code className="bg-zinc-100 px-1 rounded">{children}</code>
},
}}
>
{markdown}
</Streamup>Streamup renders whatever string you pass each render. If your source emits tokens in bursts (e.g. an LLM delivering 50 tokens at once, then idling for 200 ms), pace the source — Streamup will not buffer or smooth on its own.
With the AI SDK useChat hook, set experimental_throttle (milliseconds):
const { messages } = useChat({ experimental_throttle: 50 }) // ~20 fps capLower values feel more live; higher values are smoother and cheaper. 30–80 ms
is the useful range for chat UIs. The same idea applies to any custom source:
coalesce deltas at a fixed cadence before they reach <Streamup>.
Internally, Streamup uses useDeferredValue so a heavy tail block (table,
code block, etc.) cannot block input or scroll, and only re-heals the last
block on each chunk — keeping per-chunk cost flat as the buffer grows.
All parsing is always enabled:
- GFM: tables, strikethrough, task lists, autolinks
- Math: block equtions
- CJK: proper emphasis, strikethrough, and autolink handling for Chinese/Japanese/Korean text
- HTML: raw HTML with sanitization
- Footnotes: reference and definition syntax
Math syntax is always parsed. To render it with KaTeX, add the plugin:
pnpm add katex rehype-kateximport { Streamup } from '@opticlm/streamup'
import { katex } from '@opticlm/streamup/katex'
import 'katex/dist/katex.min.css'
// Default options
<Streamup plugins={[katex()]}>{'$$E = mc^2$$'}</Streamup>
// Custom KaTeX options
<Streamup plugins={[katex({ errorColor: '#ff0000', strict: false })]}>
{'$$E = mc^2$$'}
</Streamup>pnpm add mermaidimport { Streamup } from '@opticlm/streamup'
import { withMermaid } from '@opticlm/streamup/mermaid'
// Default
<Streamup components={withMermaid()}>
{'```mermaid\ngraph TD\n A-->B\n```'}
</Streamup>
// With mermaid config (theme, flowchart direction, etc.)
<Streamup components={withMermaid({ config: { theme: 'dark' } })}>
{'```mermaid\ngraph TD\n A-->B\n```'}
</Streamup>
// With custom fallback code component
<Streamup components={withMermaid({
config: { theme: 'forest' },
fallbackCode: MyCodeBlock,
})}>
{markdown}
</Streamup>Or use the renderer directly:
import { MermaidRenderer } from '@opticlm/streamup/mermaid'
<MermaidRenderer code="graph TD; A-->B" config={{ theme: 'dark' }} />By default, only $$...$$ block math is enabled. Enable $...$ inline math:
<Streamup singleDollarTextMath>{markdown}</Streamup>Transform or remove URLs before rendering:
<Streamup urlTransform={(url) => url.replace('http:', 'https:')}>
{markdown}
</Streamup>
// Remove all links
<Streamup urlTransform={() => null}>{markdown}</Streamup>Filter which elements are allowed to render. Returning false removes the element but keeps its children:
<Streamup allowElement={(el) => el.tagName !== 'img'}>
{markdown}
</Streamup>Replace the default sanitization schema. Spread defaultSanitizeSchema to extend it:
import { Streamup, defaultSanitizeSchema } from '@opticlm/streamup'
<Streamup sanitizeSchema={{
...defaultSanitizeSchema,
attributes: {
...defaultSanitizeSchema.attributes,
div: ['className', 'style'],
},
}}>
{markdown}
</Streamup>Add custom remark or rehype plugins via the plugin system:
import type { StreamupPlugin } from '@opticlm/streamup'
import myRemarkPlugin from 'remark-my-plugin'
import myRehypePlugin from 'rehype-my-plugin'
const myPlugin: StreamupPlugin = {
remarkPlugins: [myRemarkPlugin],
rehypePlugins: [[myRehypePlugin, { option: true }]],
}
<Streamup plugins={[myPlugin]}>{markdown}</Streamup>import { remend, parseMarkdownIntoBlocks, processMarkdown } from '@opticlm/streamup'
// Heal incomplete markdown (streaming)
remend('**bold') // '**bold**'
remend('~~strike') // '~~strike~~'
remend('[link](http') // '[link](streamup:incomplete-link)'
// Split markdown into blocks for incremental rendering
parseMarkdownIntoBlocks('# Title\n\nParagraph')
// ['# Title\n\n', 'Paragraph\n']
// Low-level: process markdown to React elements
processMarkdown('# Hello', {
components: myComponents,
processorOptions: { singleDollarTextMath: true },
urlTransform: (url) => url,
})| Prop | Type | Default | Description |
|---|---|---|---|
children |
string |
'' |
Markdown content |
streaming |
boolean |
false |
Heal incomplete markdown before rendering |
components |
Partial<Components> |
unstyled HTML | Override rendering for any element |
plugins |
StreamupPlugin[] |
[] |
Add remark/rehype plugins (e.g. katex()) |
className |
string |
— | Class on the wrapper <div> |
singleDollarTextMath |
boolean |
false |
Enable $...$ inline math syntax |
sanitizeSchema |
SanitizeSchema |
built-in | Custom HTML sanitization schema |
urlTransform |
UrlTransform |
— | Transform or remove URLs |
allowElement |
AllowElement |
— | Filter elements (return false to remove) |
GFM task lists are rendered with class names from remark-gfm:
ul.contains-task-list— the list containerli.task-list-item— each task item (contains an<input type="checkbox" disabled>)
ul.contains-task-list {
list-style: none;
padding-left: 0;
}
li.task-list-item {
display: flex;
align-items: baseline;
gap: 0.5em;
}Every HTML element can be overridden. Each component receives its standard HTML props plus node (the HAST element).
Common overrides: h1–h6, p, a, img, code, pre, blockquote, table, thead, tbody, tr, th, td, ol, ul, li, hr, strong, em, del, sup, sub.
Block code (```lang) and inline code (`code`) both render as <code>, distinguished by the data-block prop set by the rehype plugin:
data-block— present (empty string) on block code, absent on inline codedata-language— the language identifier (e.g."js","python"), only on block code
MIT