Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Building UI with AI agents
  4. React Markdown Streaming Without Flicker or Scroll Jumps
Auf Deutsch lesen
  1. Blog
  2. Building UI with AI agents
  3. React Markdown Streaming Without Flicker or Scroll Jumps
Building UI with AI agents
Building UI with AI agents

React Markdown Streaming Without Flicker or Scroll Jumps

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. Why does streamed Markdown flicker in React?
  2. How do you repair half-typed Markdown before parsing?
  3. How do you stop re-parsing the whole answer on every token?
  4. How do you render a streaming answer with the Markdown component?
  5. How do you keep a chat pinned to the bottom while it streams?
  6. How do you build a streaming Markdown chat in React?
  7. When should you use Streamdown or use-stick-to-bottom instead?
  8. What should you try next?
  9. FAQ
    1. Why does react-markdown flicker while an AI answer streams?
    2. How do I stop a streaming code block from breaking the rest of the message?
    3. Is memoizing Markdown blocks enough to fix streaming?
    4. How do I auto-scroll a React chat to the bottom only when the user is at the bottom?
    5. Does CSS overflow-anchor solve stick to bottom on its own?

TL;DR

Streamed Markdown flickers in React because every chunk re-parses the whole answer and half-typed syntax like an open ** or code fence renders raw until it closes. Repair the unfinished tail before parsing, split the text into top-level blocks and memoize them so only the last block re-renders. Follow the growing answer with a scroll loop that lets go the moment the reader scrolls up and picks up again when they return to the bottom.

Published Oct 16, 2026

You pipe a model's answer into react-markdown and it mostly works. Then the details show up: **$22,7 sits on screen as raw asterisks before it snaps to bold, a code block opens and the rest of the reply turns monospace, a table renders as a row of pipes, and the view keeps dragging the reader back down while they try to reread the first paragraph. React markdown streaming has three separate problems: half-typed syntax, re-parsing the whole answer on every chunk, and scrolling. This tutorial fixes each one, then puts them together in a small chat. The code uses the Markdown renderer, Chat Message and the useStickToBottom hook from Wingo UI, the library we build, and it explains the technique so you can apply it to plain react-markdown too.

Why does streamed Markdown flicker in React?

The usual react-markdown streaming flicker has a simple cause: Markdown is only well formed once it is finished, and a stream shows you every unfinished state in between. A model sends a few characters at a time. At any moment the tail of the text can be an open **, a backtick with no partner, a link whose URL is half there, or a table header without its separator row. The parser does exactly what CommonMark says with that input, so you see raw syntax, and a few tokens later you see the formatted version.

The second cause is cost. Each chunk produces a new source string, and react-markdown runs remark and rehype over everything received so far. The work per chunk grows with the answer, so the total grows with the square of its length. The AI SDK cookbook's earlier memoization recipe (opens in a new tab) memoized parsed blocks "to prevent them from being re-parsed and re-rendered on each token update." React still reconciles the output, so most DOM survives a re-render. The exception is a components object written inline: every render hands React new component functions, React treats them as new types and remounts those elements, and a table the reader scrolled sideways snaps back to its first column.

The third is scrolling, covered below. A streaming markdown React setup that fixes only one of the three still feels broken, so this tutorial handles all of them.

How do you repair half-typed Markdown before parsing?

Rewrite only the unfinished tail of the source, before the parser sees it, so every intermediate state is valid Markdown that looks like the finished one. The Markdown component exports its repair step as a pure function, completeMarkdown(text). Here is what it returns for real mid-stream inputs:

Mid-stream sourceAfter completeMarkdownWhy
The total is **$22,7The total is **$22,7**bold shows as bold from the first digit
Run `npm iRun `npm i`inline code closes
See [the docs](https://exaSee the docslink text shows, the half URL never becomes an href
```ts + const x = 1the same, plus a closing ```the rest of the answer stays outside the code block
```tnothing yetthe fence waits until its first line ends, so a half-typed language never picks the wrong highlighter
| Client | Amount | + |---nothing yeta table waits for its separator row
1. First + 2.1. Firsta list marker with nothing after it is dropped
snake_case and 2*3unchangedunderscores and asterisks inside words are left alone

Two rules keep the repair honest. It never touches anything inside a code block, so a ** in a code sample stays literal. And it only adds closers or holds text back; it never invents content. When the real closer arrives, the formatting is the one the reader already saw, so the text does not flip between raw and formatted.

If you stay on plain react-markdown, the same idea fits in one line while the answer streams: <ReactMarkdown>{streaming ? completeMarkdown(text) : text}</ReactMarkdown>. The function lives in components/ui/markdown.tsx, takes a string and returns a string, and uses no React.

How do you stop re-parsing the whole answer on every token?

Split the source into top-level blocks at blank lines, render each block in its own memoized component, and key the blocks by position. While text streams, only the last block's string changes, so only the last block parses again. Every finished paragraph, table and code block keeps its DOM.

The split has to respect Markdown's structure. A blank line inside a code fence does not end the block, and neither does a blank line between items of a loose list. splitMarkdownBlocks from the same file handles both:

ts
import { splitMarkdownBlocks } from "@/components/ui/markdown";
splitMarkdownBlocks("Intro\n\n```ts\na\n\nb\n```\n\n- one\n\n- two\n\nEnd");
// ["Intro", "```ts\na\n\nb\n```", "- one\n\n- two", "End"]

The earlier AI SDK recipe did the split with marked.lexer and compared each block's content in React.memo. That works for the cost, but memoization alone leaves the flicker in the last block, which is the block the reader is looking at. Run the repair first, then split.

Two caveats. Reference-style links and footnotes ([id]: https://...) resolve across the whole document, so a block-by-block render cannot resolve them. While text streams, a [docs][1] whose definition sits in another block shows as literal brackets. Once streaming turns off, the Markdown component switches to a one-pass render if the source contains a definition (or you passed your own remark or rehype plugins), and the references resolve. Also keep any components override stable (module scope or useMemo). An inline object defeats the memo, so every block re-renders on every chunk, and inline functions remount their elements as described above.

Press Replay as a stream below and watch the bold total and the table header. They stay hidden until they are complete instead of flashing raw characters, and each new block fades in under a pulsing caret.

Press Replay as a stream: half-typed bold, code spans and the table header wait until they are complete, and only the last block re-renders
$ npx wingo-ui@latest add markdown
ProMarkdown docs

How do you render a streaming answer with the Markdown component?

Install it, append each chunk to state, and pass streaming while the answer is arriving:

bash
npx wingo-ui@latest add markdown
tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
export function StreamedAnswer({ question }: { question: string }) {
const [text, setText] = useState("");
const [streaming, setStreaming] = useState(false);
const abort = useRef<AbortController | null>(null);
// stop the request when the component unmounts
useEffect(() => () => abort.current?.abort(), []);
async function ask() {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
setText("");
setStreaming(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: question }] }),
signal: controller.signal,
});
if (!response.ok || !response.body) return;
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
setText((current) => current + value);
}
} catch {
// aborted or failed: keep what arrived
} finally {
if (abort.current === controller) setStreaming(false);
}
}
return (
<div className="flex flex-col gap-3">
<Button variant="soft" size="sm" onClick={ask}>
Ask
</Button>
<Markdown streaming={streaming} variant="compact" caret="bar" allowImages={false}>
{text}
</Markdown>
</div>
);
}

/api/chat is your route handler returning a plain text stream. With streaming on, the component repairs the tail, re-parses only the last block, fades new blocks in, shows the caret after the last word and sets aria-busy on its root. The caret is dot by default; bar, block and none are the other options. Turn streaming off when the stream ends: the repair stops, the caret goes, and the finished blocks keep their DOM.

Two settings matter for model output. variant="compact" keeps headings small, so an answer that starts with # does not shout. allowImages={false} renders every image as a link with its alt text, so untrusted output cannot load a tracking pixel. Raw HTML in the source is never rendered.

How do you keep a chat pinned to the bottom while it streams?

Follow new content only while the reader is at the bottom, stop the moment they scroll up, and start again when they scroll back down or send a message. The first version of chat auto scroll to bottom in React is often a useEffect that scrolls whenever the messages array changes. During a stream it fails both ways: it misses height that changes without a state update, and it drags back a reader who scrolls up. Four rules make a stick to bottom chat feel right:

  • Let go on intent. At the very bottom, a wheel turn upward has not moved scrollTop yet when the next chunk lands, so a position check scrolls the reader right back. The hook stops following on the upward wheel event itself, and on any upward scroll while the content did not shrink, even inside the threshold.
  • Watch the content's size. Height also changes without a state update: an image finishes loading, a reasoning panel expands. A ResizeObserver on the growing element catches every change whatever caused it, and a MutationObserver picks up a child swapped in later, such as an empty state replaced by the list.
  • Follow outside React. Following writes scrollTop in a requestAnimationFrame loop that re-reads the target every frame, so text that grows mid-animation is still caught. React state changes only when isAtBottom or isFollowing flips.
  • Respect reduced motion. With reduced motion on, following jumps instead of easing.

You may see CSS scroll anchoring suggested for this. It was designed to keep visible content still when something changes above it; pinning to the bottom with it needs an anchor-element trick, and it gives you no isAtBottom state for a jump button. MDN's overflow-anchor page (opens in a new tab) lists it as Baseline only since September 2026 (checked October 2026), so phones on older browser versions still lack it.

bash
npx wingo-ui@latest add use-stick-to-bottom
tsx
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
threshold: 72, // px from the end that still counts as the bottom
initial: "instant", // open a conversation at its end
resize: "smooth", // glide after streamed text instead of jumping
});

scrollRef goes on the overflow-y-auto element, contentRef on the element inside it that grows. Scroll up while the answer below is still arriving: following stops at once, the text keeps growing underneath and a New messages button appears.

Scroll up while an answer streams: following stops, the text keeps growing below and a New messages button brings you back
$ npx wingo-ui@latest add use-stick-to-bottom
ProuseStickToBottom docs

How do you build a streaming Markdown chat in React?

Render each message with Chat Message inside a useStickToBottom scroller, and on every chunk replace only the streaming message's object. Chat Message renders its content through the same Markdown component and turns on streaming when status is "streaming". It is wrapped in memo, so the finished messages above keep their props and do not render again. Here is the whole thing in one file:

bash
npx wingo-ui@latest add chat-message use-stick-to-bottom input
tsx
"use client";
import { useRef, useState, type FormEvent } from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChatMessage, type ChatMessageData } from "@/components/ui/chat-message";
import { Input } from "@/components/ui/input";
import { useStickToBottom } from "@/hooks/use-stick-to-bottom";
import { createChatId } from "@/lib/ai-chat";
export function StreamingChat() {
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [draft, setDraft] = useState("");
const abort = useRef<AbortController | null>(null);
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom();
// a new object for the one message that changed, so memo skips the rest
const patch = (id: string, change: Partial<ChatMessageData>) =>
setMessages((list) => list.map((m) => (m.id === id ? { ...m, ...change } : m)));
async function send(event: FormEvent) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
setDraft("");
const question: ChatMessageData = { id: createChatId("u"), role: "user", content: text };
const history = [...messages, question].map(({ role, content }) => ({ role, content }));
const id = createChatId("a");
setMessages((list) => [...list, question, { id, role: "assistant", content: "", status: "pending" }]);
// sending always returns to the bottom, even after the reader scrolled up
scrollToBottom("smooth");
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
let content = "";
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("The assistant is unavailable.");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
content += value;
patch(id, { content, status: "streaming" });
}
patch(id, { status: "complete" });
} catch {
patch(id, { status: controller.signal.aborted ? "stopped" : "error" });
}
}
const last = messages.at(-1);
return (
<div className="flex h-dvh flex-col">
<div className="relative min-h-0 flex-1">
<div ref={scrollRef} className="h-full overflow-y-auto overscroll-contain">
<div ref={contentRef} className="mx-auto flex max-w-2xl flex-col gap-6 p-4">
{messages.map((m) => (
<ChatMessage key={m.id} {...m} isLatest={m.id === last?.id} />
))}
</div>
</div>
{!isAtBottom && (
<Button
size="sm"
radius="full"
leftIcon={<ArrowDown />}
className="absolute bottom-3 left-1/2 -translate-x-1/2"
onClick={() => scrollToBottom()}
>
New messages
</Button>
)}
</div>
<form onSubmit={send} className="mx-auto flex w-full max-w-2xl gap-2 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<Input value={draft} onValueChange={setDraft} placeholder="Ask about your invoices" aria-label="Message" className="flex-1" />
<Button type="submit">Send</Button>
</form>
</div>
);
}

What each piece does while an answer streams: the pending message shows typing dots until the first chunk, the streaming message repairs its Markdown and re-parses only its last block, the other messages skip rendering, and the scroller follows the growing answer until the reader scrolls up. An aborted request keeps the text that arrived and marks the message as stopped.

This file leaves out what a production thread adds: a count of new replies on the jump button, keeping the reader's place when older history loads at the top, and a polite live region that announces when a reply starts and when it is ready instead of reading every streamed word. The Chat Thread component does those. Its composer slot usually takes a Prompt Input, which ignores Enter while an IME is composing and, with position="fixed", stays above the phone keyboard. The React AI chat UI guide walks through each part.

When should you use Streamdown or use-stick-to-bottom instead?

When you want free, open-source building blocks and are fine composing them yourself. Both are good choices, as of October 2026:

  • Streamdown (opens in a new tab) from Vercel is Apache-2.0 licensed and describes itself as a drop-in replacement for react-markdown designed for AI streaming, with memoized rendering. Its termination docs (opens in a new tab) say it uses the remend preprocessor to close incomplete bold, italic, inline code, strikethrough, links and block math. If your answers contain math, pick it: our Markdown component renders GitHub flavored Markdown without math support. If your app already uses the AI SDK, it is also the natural pick.
  • use-stick-to-bottom (opens in a new tab) from StackBlitz Labs is an MIT-licensed, zero-dependency hook and component with velocity-based spring scrolling, and it lets the reader cancel stickiness by scrolling up. Our hook shares its name and its goal.

The Wingo UI versions fit when you want the renderer, the message and the scroller to share one look and one set of props, with phone behavior already handled: text stays at 15px in chat, wide tables and code scroll inside their own box, and a long press on a message opens its actions in a bottom sheet on touch screens. Markdown, Chat Message and useStickToBottom are part of Wingo UI Pro (pricing).

What should you try next?

Open the Markdown docs, press Replay as a stream in the playground, then install it with npx wingo-ui@latest add markdown. If you build with a coding agent, the component library MCP guide shows how to let it install these components directly. More posts like this one are in Building UI with AI agents.

Components in this post

  • Markdown

    A React Markdown renderer in the library's look: scrolling tables, highlighted code, task lists and a flicker-free streaming mode for AI.

    Pro
  • Chat Message

    A React AI chat message: the question in a bubble, the answer with its thinking, tool calls, markdown, sources and actions.

    Pro
  • useStickToBottom

    A React hook that keeps a chat, a log or streaming output pinned to its bottom while it grows, and lets go when the reader scrolls up.

    Pro

FAQ

Why does react-markdown flicker while an AI answer streams?

Each chunk changes the source string, so react-markdown parses and renders the whole answer again, and unfinished syntax such as an open ** or a half-typed link renders as raw characters until its closer arrives. The text then flips between raw and formatted on every few tokens.

How do I stop a streaming code block from breaking the rest of the message?

Close the open fence yourself before parsing: if the text has an opening ``` without a closer, append one. Also hold the fence back until its first line ends, or a half-typed language name can briefly pick the wrong highlighting.

Is memoizing Markdown blocks enough to fix streaming?

It fixes the cost, because finished blocks stop re-parsing, but the last block still shows half-typed bold, links and table headers. You need both: block memoization for speed and a repair step on the tail for the flicker.

How do I auto-scroll a React chat to the bottom only when the user is at the bottom?

Track whether the scroller is within a small threshold of its end, follow new content only while that is true, and stop following as soon as the reader scrolls up, even by a few pixels. A ResizeObserver on the content catches every change in height, including ones without a state update, such as an image that finishes loading.

Does CSS overflow-anchor solve stick to bottom on its own?

Not reliably for streaming. Scroll anchoring was designed to keep visible content still when something changes above it, it gives you no at-bottom state for a jump button, and MDN lists overflow-anchor as Baseline only since September 2026, so older browsers on phones still lack it.

  • Markdown
  • Streaming
  • AI chat
  • React
  • Scrolling

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Building UI with AI agents

Nextjs llms.txt: Generate llms-full.txt and .md Pages

Build a nextjs llms.txt with App Router route handlers: an index, an llms-full.txt and a .md twin of every page, all generated from one content source.

SRSerban Rusu·Oct 16, 2026·10 min read
Building UI with AI agents

React AI Chat UI: Streaming, Auto-Scroll and Mobile

How a production React AI chat UI handles streaming, auto-scroll, IME input and the phone keyboard, part by part, with code for the Next.js App Router.

SRSerban Rusu·Oct 9, 2026·10 min read
Component guides

React Rich Text Editor: Tiptap, Markdown and Mentions

Build a React rich text editor on Tiptap with Markdown shortcuts, @mentions and image uploads that saves HTML, JSON or Markdown and works well on a phone.

SRSerban Rusu·Oct 19, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI