Markdown

Renders a Markdown string using Wander's typography primitives, with GitHub-flavored extensions and streaming-safe parsing.

Installation

pnpm add @wandercom/design-system-web

Usage

7 lines
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.

Showcase

A full pass over the supported Markdown features — headings, paragraphs, lists, links, inline and block code, blockquotes, GFM tables, and rules.

Loading example...

Streaming

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.

Loading example...
6 lines
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:

19 lines
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.

16 lines
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);

Element overrides

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.

9 lines
<Markdown
  components={{
    a: ({ href, children }) => (
      <CustomLink href={href}>{children}</CustomLink>
    ),
  }}
>
  {source}
</Markdown>

Element mapping

MarkdownRenders as
# h1Heading variant="display-sm"
## h2Heading variant="headline-lg"
### h3Heading variant="headline"
#### h4Heading variant="headline-sm"
##### h5Text variant="body-lg" weight="medium"
###### h6Text variant="body" weight="medium"
ParagraphText variant="body-long"
Link<a> with primary color, underline, external safe
List itemText variant="body-long" (via asChild)
Inline codebg-surface-secondary rounded chip, mono
Code blockbg-surface-secondary rounded panel, mono
BlockquoteLeft border + tertiary italic
TableBordered table inside an overflow-x scroll wrapper
--- (rule)Separator

Security

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.

Props

children?:

string
Markdown source string. Equivalent to `content`; if both are provided, `content` wins.

content?:

string
Markdown source string. Preferred for controlled / streaming usage.

components?:

Components
Per-element overrides forwarded to react-markdown. Merged on top of the design-system defaults.

className?:

string
Additional CSS classes applied to the root wrapper.

…rest

ComponentProps<'div'>
All other props are spread onto the root wrapper.

Notes & limitations

  • 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 / pre via the components prop if you need highlighting in a particular consumer.
  • External links (http:// / https://) automatically get target="_blank" and rel="noopener noreferrer". In-app routing should be handled by overriding the a renderer.
  • Mid-stream tokens may render briefly as plain text until their closing delimiter arrives — this is react-markdown's natural behavior.
Markdown