Render mathematical expressions in chat messages using KaTeX.
Render LaTeX mathematical expressions in chat messages using KaTeX.
Install dependencies
npm install katex rehype-katex remark-mathAdd KaTeX CSS to your layout
import "katex/dist/katex.min.css";Update markdown-text.tsx
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm, remarkMath]} // add remarkMath
rehypePlugins={[rehypeKatex]} // add rehypeKatex
className="aui-md"
components={defaultComponents}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);Using Streamdown as your renderer? Math support is a first-party plugin — no remark or rehype packages needed.
Install dependencies
npm install @streamdown/math katexAdd KaTeX CSS to your layout
import "katex/dist/katex.min.css";Pass the math plugin to StreamdownTextPrimitive
import { math } from "@streamdown/math";
import "katex/dist/katex.min.css";
<StreamdownTextPrimitive plugins={{ math }} />Supported Formats
By default, remark-math (react-markdown path) supports:
$...$for inline math$$...$$for display math- Fenced code blocks with the
mathlanguage identifier
Supporting Alternative LaTeX Delimiters
Many language models generate LaTeX using different delimiter formats:
\(...\)for inline math\[...\]for display math- Custom formats like
[/math]...[/math]
You can use the preprocess prop on MarkdownTextPrimitive to normalize these formats before parsing:
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
preprocess={normalizeCustomMathTags}
className="aui-md"
components={defaultComponents}
/>
);
};
// Your LaTeX preprocessing function
function normalizeCustomMathTags(input: string): string {
return (
input
// Convert [/math]...[/math] to $$...$$
.replace(/\[\/math\]([\s\S]*?)\[\/math\]/g, (_, content) => `$$${content.trim()}$$`)
// Convert [/inline]...[/inline] to $...$
.replace(/\[\/inline\]([\s\S]*?)\[\/inline\]/g, (_, content) => `$${content.trim()}$`)
// Convert \( ... \) to $...$ (inline math) - handles both single and double backslashes
.replace(/\\{1,2}\(([\s\S]*?)\\{1,2}\)/g, (_, content) => `$${content.trim()}$`)
// Convert \[ ... \] to $$...$$ (block math) - handles both single and double backslashes
.replace(/\\{1,2}\[([\s\S]*?)\\{1,2}\]/g, (_, content) => `$$${content.trim()}$$`)
);
}Inside MarkdownTextPrimitive, the streamed text first passes through preprocess (delimiter normalization) and then through useSmooth (character-by-character accumulation), and only then reaches the markdown parser. Both run before remark-math sees the text, so delimiter replacement and the streaming smoothing are streaming-safe — partially-received delimiters are accumulated in the smoothing buffer rather than parsed mid-fragment.