Markdown
Renders a Markdown string using Wander's typography primitives, with GitHub-flavored extensions and streaming-safe parsing.
pnpm add @wandercom/design-system-web
import { Markdown } from '@wandercom/design-system-web/ui/markdown';
export function Example() {
return (
<Markdown>{`# Hello\n\n**World** with [a link](https://example.com).`}</Markdown>
);
}The component accepts the source either as a children string or via the
content prop. Use content for controlled / streaming usage; if both are
provided, content wins.
A full pass over the supported Markdown features — headings, paragraphs, lists, links, inline and block code, blockquotes, GFM tables, and rules.
Markdown is safe to re-render as content grows token-by-token. The parser
tolerates incomplete trailing tokens (e.g. an open **), so streamed assistant
output renders gracefully. Layout may flicker briefly while a delimiter is
unbalanced — pair the component with one of two hooks from
@wandercom/design-system-shared for stable frames without dropping the final
token.
If you have a string that updates externally, wrap it in useThrottledValue.
If you have a Claude streaming response, pipe it through useClaudeStream;
for OpenAI, use the sibling useOpenAIStream. Both handle the vendor SSE
event shape and throttling for you.
import { useThrottledValue } from '@wandercom/design-system-shared/hooks/use-throttled-value';
const [content, setContent] = useState('');
const throttled = useThrottledValue(content, { intervalMs: 80 });
// …append tokens to `content` as they arrive
<Markdown content={throttled} />;For Anthropic SDK consumers, useClaudeStream accepts the
AsyncIterable<MessageStreamEvent> returned by client.messages.stream(...)
directly — it accumulates text_delta events, throttles re-renders, and
exposes a streaming flag driven by message_stop:
import Anthropic from '@anthropic-ai/sdk';
import { useClaudeStream } from '@wandercom/design-system-shared/hooks/use-claude-stream';
const client = new Anthropic();
function Reply({ messages }: { messages: Anthropic.MessageParam[] }) {
const stream = useMemo(
() =>
client.messages.stream({
model: 'claude-opus-4-7',
max_tokens: 1024,
messages,
}),
[messages]
);
const { content, isStreaming, stop } = useClaudeStream(stream);
return <Markdown content={content} />;
}Pass an onEvent callback to surface non-text events (tool use, thinking,
usage). The hook deliberately ignores input_json_delta and thinking_delta
in content — route those through onEvent and parse them in your own
state.
For OpenAI consumers, useOpenAIStream accepts either streaming shape — the
Responses API (client.responses.stream(...)) or Chat Completions
(client.chat.completions.create({ stream: true })) — and discriminates
internally. Tool-call deltas are surfaced via onEvent; the wire-level
data: [DONE] sentinel of Chat Completions is consumed by the SDK before
iteration sees it, so the hook doesn't need to handle it.
import OpenAI from 'openai';
import { useOpenAIStream } from '@wandercom/design-system-shared/hooks/use-openai-stream';
const client = new OpenAI();
// Responses API
const stream = client.responses.stream({ model: 'gpt-4o', input });
const { content } = useOpenAIStream(stream);
// Chat Completions
const chatStream = client.chat.completions.create({
model: 'gpt-4o',
messages,
stream: true,
});
const { content: chatContent } = useOpenAIStream(chatStream);Pass a components map to override individual element renderers. Keys you
provide replace the design-system defaults; everything else stays mapped to the
DS primitives.
<Markdown
components={{
a: ({ href, children }) => (
<CustomLink href={href}>{children}</CustomLink>
),
}}
>
{source}
</Markdown>Raw HTML in the source is not rendered (no rehype-raw), which is the
right default for chat surfaces where the source may be untrusted assistant
output.
children?:
content?:
components?:
className?:
…rest
- No syntax highlighting on code blocks. The chat surface intentionally keeps
this minimal; the docs site already runs a server-side highlighter for its
own MDX. Wrap
code/previa thecomponentsprop if you need highlighting in a particular consumer. - External links (
http:///https://) automatically gettarget="_blank"andrel="noopener noreferrer". In-app routing should be handled by overriding thearenderer. - Mid-stream tokens may render briefly as plain text until their closing
delimiter arrives — this is
react-markdown's natural behavior.