KaTeX-powered math support for @tanstack/markdown. Renders inline $...$ and display $$...$$ math using the extension API.
@tanstack/markdown ships with a fast, streaming-friendly parser, but it has no built-in math support. This package provides a set of extensions that can be dropped into parseMarkdown() or the React <Markdown> component to add TeX/LaTeX rendering with KaTeX (or any custom renderer).
These extensions were originally extracted from a dense-math, 900+ page corpus and validated with the @tanstack/markdown streaming pipeline.
pnpm add tanstack-markdown-math @tanstack/markdown katex
# npm install tanstack-markdown-math @tanstack/markdown katex
# yarn add tanstack-markdown-math @tanstack/markdown katex@tanstack/markdown (>=1.0.0) and katex are peer dependencies. A custom render option lets you avoid KaTeX at runtime, but it remains a peer dependency for now. react (>=18) is an optional peer, only needed for the tanstack-markdown-math/react subpath.
import { Markdown } from '@tanstack/markdown/react'
import { mathExtension } from 'tanstack-markdown-math'
export function Page() {
return (
<Markdown extensions={[mathExtension()]} allowHtml>
{`The energy is $E = mc^2$.
$$
\\frac{1}{2}
$$`}
</Markdown>
)
}Important: in the default
htmloutput mode, rendered math is emitted ashtml/inlineHtmlnodes, so you must setallowHtml: truefor anything to show up in the React component. See Math withoutallowHtmlfor thecomponentoutput mode (block and inline).
import { parseMarkdown } from '@tanstack/markdown'
import { mathExtension } from 'tanstack-markdown-math'
const doc = parseMarkdown('Energy $E = mc^2$.', {
extensions: mathExtension(),
})import {
mathExtension,
mathBlockExtension,
mathInlineExtension,
} from 'tanstack-markdown-math'mathExtension(opts?)→[mathBlockExtension(opts), mathInlineExtension(opts)]mathBlockExtension(opts?)→ parses$$...$$blocks intohtmlnodes (componentnodes withoutput: 'component').mathInlineExtension(opts?)→ registers a$inline parser (@tanstack/markdown1.0inlineParser) that converts$...$/$$...$$intoinlineHtmlnodes (inlineComponentnodes withoutput: 'component').
The extensions also export helpers used when pre-processing content through other tools:
protectMath(content)→ deprecated (not needed with@tanstack/markdown>= 1.0; math is parsed before emphasis/escape handling). Replaces replaces\,_and*inside math spans with private-use characters (MATH_BACKSLASH_SUB,MATH_UNDERSCORE_SUB,MATH_ASTERISK_SUB) so other markdown passes cannot unescape TeX commands or turn subscripts into emphasis. Kept for pre-protected content.restoreMathChars(tex)→ deprecated, inverse mapping, applied automatically before rendering (no-op when content was not protected).
| Option | Type | Default | Description |
|---|---|---|---|
render |
(tex: string, displayMode: boolean) => string |
KaTeX | Custom renderer. Receives raw TeX and whether it should be rendered in display mode. Great for MathJax, server-side rendering, or mocking in tests. |
allowSpaces |
boolean |
false |
Inline-math only. When true, allows spaces inside delimiters ($ x $). When false, matches only non-whitespace math ($x$), preventing false positives like $5. |
output |
'html' | 'component' |
'html' |
Block and inline math. 'component' emits component/inlineComponent nodes carrying raw TeX, removing the allowHtml requirement (see below). |
tagName |
string |
'MathBlock' |
Block-math only. Tag name of the component node, to be mapped through the renderers' components option. |
inlineTagName |
string |
'MathInline' |
Inline-math only. Tag name of the inlineComponent node, to be mapped through the renderers' components option. |
unclosedBlock |
boolean |
true |
Block-math only. true renders an unclosed $$ block as partial math (streaming-friendly). false leaves it as plain text until the closing $$ arrives. |
cache |
boolean | number |
true |
Render cache (LRU, 1000 entries). false disables it — use this with an impure custom render (side effects, non-deterministic output). A number sets a custom entry limit. |
mathBlockExtension accepts render, output, tagName, unclosedBlock and cache; mathInlineExtension accepts render, allowSpaces, output, inlineTagName and cache.
With output: 'component', math is emitted as component (block) / inlineComponent (inline) nodes carrying the raw TeX in properties.tex, instead of pre-rendered html/inlineHtml nodes. The AST stays serialization-clean and no allowHtml is needed.
Requires
@tanstack/markdown>= 1.0.0.
HTML renderer — zero configuration: the extension ships a renderHtml hook that renders both block and inline component nodes.
import { renderHtml } from '@tanstack/markdown'
import { mathExtension } from 'tanstack-markdown-math'
const html = renderHtml('Energy $E = mc^2$.\n\n$$\\frac{1}{2}$$', {
extensions: mathExtension({ output: 'component' }),
}) // → KaTeX HTML, no allowHtmlReact renderer — map the tag names to the provided components:
import { Markdown } from '@tanstack/markdown/react'
import { mathExtension } from 'tanstack-markdown-math'
import { MathBlock, MathInline } from 'tanstack-markdown-math/react'
export function Page() {
return (
<Markdown
extensions={mathExtension({ output: 'component' })}
components={{ MathBlock, MathInline }}
>
{'The energy is $E = mc^2$.\n\n$$\\frac{1}{2}$$'}
</Markdown>
)
}MathBlock renders properties.tex via KaTeX in display mode, MathInline in inline mode; provide your own components ({ tex } props) for full control. Without a components mapping, the nodes fall back to plain <MathBlock tex="..."> / <MathInline tex="..."> elements.
mathInlineExtension()$x$→ rendered math$5or$ 5 $→ left as literal text$5 and $10,$5-$10→ left as literal text (no false monetary positives: whitespace before the closing$, or a digit right after it, rejects the span)\$5→ escaped dollar, literal text;$a \$ b$→ valid math containing an escaped dollar_,*and\inside math need no pre-protection (protectMathis no longer required)
mathInlineExtension({ allowSpaces: true })- Matches any pair of
$...$on the same line, including$ 5 $. - Use this only when your content is audited, because it will happily turn
$5 and $10into two math expressions.
Inline math is detected in:
- paragraphs
- headings
- strong / emphasis / strikethrough
- links
- blockquotes
- callouts
- list items (recursive)
- table cells (recursive)
Inline math is explicitly skipped inside:
- code blocks
- inline code
- structural block HTML (e.g.
<div>...</div>)
Math inside inline HTML is rendered: $ spans inside inlineHtml nodes (e.g. <u>$V_1$</u>) and inside inline-style HTML blocks (<u>, <span>, <em>, <strong>, <b>, <i>, <font>, <small>, <sub>, <sup>, <mark>) are converted in place.
Fenced display math recognizes both multi-line and single-line forms:
$$
\\frac{1}{2}
$$$$ \\frac{1}{2} $$Unclosed $$ blocks are consumed to the end of the document and rendered as partial math without crashing. This is important for streaming UIs. Set unclosedBlock: false to leave unclosed blocks as plain text instead.
The opening fence may be indented by at most 3 spaces; 4-space-indented $$ stays an indented code block.
Because @tanstack/markdown extensions run during document parsing, both block and inline extensions handle incomplete input with the same parser-created AST. An unclosed $$ block produces an html node (component node in component mode) rendered as partial math; an unclosed $...$ pair is left as literal text until the closing delimiter arrives.
- Several inline math expressions on the same line are each rendered separately.
- Math inside inline HTML (
<u>$x$</u>) is rendered; structural block HTML containing$is left untouched. - Code fences / inline code containing
$are preserved verbatim. - Invalid TeX renders as a neutral error element instead of raw KaTeX output (default renderer only):
<span class="katex-error-badge">inline and<div class="katex-error-box">in display mode, with English text ("KaTeX error"). Style these classes yourself; the React components use the same output. - Heading anchors: math is now parsed as part of the heading's inline content, so generated heading slugs are derived from the heading's plain text and no longer include math fragments.
- Math is trimmed in both output modes (
$$ x $$→x). mathBlockExtensionruns before built-in block parsers because it is registered earlier in the extension array.
These notes follow the upstream discussion in TanStack/markdown#13 (consolidated into #9):
-
First-class math nodes vs. html injection The default mode emits
html/inlineHtmlnodes. Thecomponentoutput mode usesComponentNodeandInlineComponentNode: raw TeX in the AST, rendering deferred to the renderer (renderHtmlhook / Reactcomponentsmap), noallowHtml. -
inlineParserinstead of an AST walker Since@tanstack/markdown1.0, extensions can register aninlineParserwithmarkers: '$'. The parser sees the raw source before emphasis, escapes and links are resolved, so math bodies containing_,*or\are never corrupted andprotectMathis unnecessary. Spans are recognised in every inline context (headings, links, lists, tables, ...) and never inside code. -
Pandoc-style delimiter rules Single
$spans reject whitespace after the opener and before the closer, reject a closer followed by a digit ($5-$10), honour\$escapes, and stop at blank lines. Scanning is linear. A smalltransformDocumentremains only for block-level raw HTML nodes (allowHtml), and it builds new nodes instead of mutating the input so cached ASTs can be re-parsed safely.
- Peer dependency raised to
@tanstack/markdown>= 1.0.0. Migrate withpnpm add @tanstack/markdown@^1 tanstack-markdown-math@^1(or the npm/yarn equivalent). protectMath/restoreMathCharsare deprecated. Remove yourprotectMath(content)calls before parsing: math containing_,*and\now works as-is. Both functions remain exported for already-protected content.- Heading slugs change when headings contain inline math. Math is now parsed inside the heading's inline content, so regenerate any stored heading anchors / table-of-contents links / deep links for headings that contain
$...$. - Default error output changed. Invalid TeX now renders
<span class="katex-error-badge">(inline) /<div class="katex-error-box">(display) with English text ("KaTeX error") instead of Tailwind utility classes and French strings. Add your own CSS forkatex-error-badge/katex-error-box, or pass a customrenderto keep your previous markup.
MIT © 2026 Bertrand Gressier