Streaming Rendering
Handle LLM streamed Markdown output: syntax completion and caching, animation, and tail suffix.
streaming accepts a boolean or an object. A boolean is the preset: true means streaming is in progress — the same as hasNextChunk: true with incremental, incompleteMarkdown: 'complete' and typewriter switched on; false means the stream has ended and the final content is rendered. Pass the isStreaming flag your app already has: <XMarkdown content={content} streaming={isStreaming} />. Pass an object for per-option control; the object form's behaviour and defaults are unchanged.
| Parameter | Description | Type | Default |
|---|---|---|---|
| hasNextChunk | Whether more chunks are coming | boolean | false |
| incremental | Split the document at headings so each chunk only re-parses and re-renders the last, still-growing section. Pass an object to set minSectionChars (shorter sections merge into the next one) and keepSectionsOnEnd (default true: sections are kept once the stream ends and mounted custom components are not remounted; false: the whole document is re-rendered at once when the stream ends — turn it off when using marked extensions with document-wide state) | boolean | { minSectionChars?: number; keepSectionsOnEnd?: boolean } | false |
| incompleteMarkdown | How unfinished syntax is shown: placeholder holds it back or shows the placeholder component; complete shows half-written emphasis, inline code, link text and list items as finished text right away | 'placeholder' | 'complete' | 'placeholder' |
| incompleteMarkdownComponentMap | Component mapping for incomplete syntax; an explicit entry always wins over complete | Partial<Record<Exclude<StreamCacheTokenType, 'text'>, string>> | {} |
| typewriter | Typewriter effect: arriving content is revealed at a steady pace that follows the chunk cadence instead of appearing in blocks | boolean | TypewriterConfig | false |
| enableAnimation | Enable fade-in animation | boolean | false |
| animationConfig | Animation config | AnimationConfig | { fadeDuration: 200, easing: 'ease-in-out' } |
| tail | Enable tail indicator | boolean | TailConfig | false |
incrementalonly splits before a column-0 ATX heading;#lines inside fenced code, HTML blocks (<div>,<pre>,<script>, comments, …) and$$math are not headings. Splitting is disabled once a link reference or footnote definition appears, or while a custom component tag spans the boundary. Sections render inside the same root element, so the DOM is identical to a whole-document render.
| Property | Description | Type | Default |
|---|---|---|---|
| unit | Reveal unit. char reveals character by character; sentence reveals up to the next delimiter at once (delimiters inside fenced or inline code do not count, a newline always does) | 'char' | 'sentence' | 'char' |
| delimiters | Sentence delimiters | string[] | ['。', '!', '?', '.', '!', '?', '\n'] |
| minCps | Minimum speed in characters per second; the speed adapts to the chunk cadence | number | 24 |
| maxCps | Maximum speed in characters per second | number | 3000 |
| pauseMs | With unit: 'char', pause after revealing a delimiter (ms) | number | 0 |
| maxSentenceChars | With unit: 'sentence', the longest run since the previous delimiter that is held back; beyond it text is revealed character by character until the next delimiter, so a long URL or one-line JSON blob does not stay invisible | number | 120 |
What is shown is always a prefix of
content, and a cut never lands inside an emoji (including multi-code-point sequences such as 👨👩👧👦, 🇨🇳, 👍🏽, ❤️) or an accented character; everything is revealed at once whenhasNextChunkbecomesfalse.
| Property | Description | Type | Default |
|---|---|---|---|
| content | Content to display as tail | string | '▋' |
| component | Custom tail component, takes precedence over content | React.ComponentType<{ content?: string }> | - |
| Property | Description | Type | Default |
|---|---|---|---|
| fadeDuration | Duration in ms | number | 200 |
| easing | CSS easing function | string | 'ease-in-out' |
| splitBy | Fade-in unit. chunk fades in each arriving piece of text on its own; sentence joins it to the current sentence and starts a new unit only after a delimiter. Use sentence together with typewriter | 'chunk' | 'sentence' | 'chunk' |
| delimiters | Delimiters used when splitBy is sentence | string[] | ['。', '!', '?', '.', '!', '?', '\n'] |
| maxSentenceChars | With splitBy: 'sentence', the most characters one fade-in unit holds; text arriving beyond it starts a new unit | number | 120 |
The tail displays
▋by default. You can customize the character viacontent, or pass a custom React component viacomponentfor animations, delayed display, and other effects.tsx// Custom tail component exampleconst DelayedTail: React.FC<{ content?: string }> = ({ content }) => {const [visible, setVisible] = useState(false);useEffect(() => {const timer = setTimeout(() => setVisible(true), 2000);return () => clearTimeout(timer);}, []);if (!visible) return null;return <span className="my-tail">{content}</span>;};
| Property | Description | Type | Default |
|---|---|---|---|
| debug | Whether to enable performance monitor panel | boolean | false |
⚠️ debug is for development only. Disable in production to avoid overhead and information leakage.
| TokenType | Example |
|---|---|
link | [text](https://example |
image |  => <CodeHighlighter lang={props.lang}>{String(props.children)}</CodeHighlighter>,arePropsEqualIgnoringDomNode,);// Kept outside the component: an inline literal is a new object on every renderconst components = { code: Code };<XMarkdown content={content} streaming={isStreaming} components={components} />;
It does not help container components whose children is an array of elements (such as table); those are covered by incremental. Keep components, config, streaming and similar objects outside the component or in useMemo; inline literals rebuild the parser on every render.
hasNextChunk always be true?No. Set it to false for the last chunk so placeholders can be flushed into final rendered content. With the boolean form, pass your isStreaming flag straight to streaming and this happens automatically.