Skip to content

@ai-markdown/react

Documentation · Examples · Website

@ai-markdown/react stable @ai-markdown/react monthly downloads TypeScript declarations included MIT license

React 19

React 19 application adapter. For Vue 3.5, use @ai-markdown/vue. For package selection, CSS setup and API differences, see Getting started.

3.0.0: React and Vue adapters share the public @ai-markdown/core and @ai-markdown/engine packages. See the migration guide.

@ai-markdown/react renders accumulated Markdown strings in React. It combines GFM, KaTeX math, CJK delimiter handling, optional typography transforms, and a verified incremental parsing path for append-heavy content. Use it with the built-in CSS or supply your own typography and element components.

The React adapter owns the React lifecycle, context hooks, document coordination, and cached element construction. Its exact-version engine dependency owns syntax processing. Code fences remain code text in the React adapter; syntax highlighting, JSON presentation, and rendered Mermaid diagrams are supplied by the Mantine package or your custom pre component. Start with the installation and quick start, then use the API tables to make each customization explicit.

Upgrading from 1.x? v2.0.0 removes the 1.x object-based config channel (and its integrator default channel) in favor of flat props, a sealed engine-plugin catalog, and five narrow hooks. See the migration guide for the complete old → new mapping with before/after code.

  • GFM — tables, strikethrough, task lists, autolinks via remark-gfm
  • LaTeX math — inline and display math rendered with KaTeX; smart preprocessing handles currency $ signs, bracket delimiters (\[...\], \(...\)), pipe escaping, and mhchem commands
  • Emoji — shortcode support (:smile:) via remark-emoji
  • CJK-friendly — CJK-aware emphasis/strikethrough parsing, optional pangu spacing, and configurable fonts; source line breaks still become <br>
  • Extra syntax — highlight (==text==), definition lists
  • Display optimizations — SmartyPants typography, pangu CJK spacing, HTML comment removal
  • Streaming-aware — built-in streaming flag propagated via context for custom components
  • Smooth streamingAIMarkdownSmoothStream shell (and the useSmoothStream hook beneath it) reveals bursty token chunks as a steady grapheme-by-grapheme typewriter; see apps/docs/content/guides/smooth-streaming.md
  • Customizable — swap typography, color scheme, individual markdown element renderers, and inject extra style wrappers
  • Metadata context — pass arbitrary data to deeply nested custom components without prop drilling, isolated from render state to avoid unnecessary re-renders
  • TypeScript — fully typed flat props plus a metadata generic (AIMarkdownProps<TMetadata>)
PackageRoleVersion policy
@ai-markdown/coreFramework-independent sessions, block planning, contributions and smooth coordinationRelease train; exact engine dependency
@ai-markdown/reactThe React renderer — <AIMarkdown>, <AIMarkdownSmoothStream>, <AIMarkdownDocuments>, hooks, providersRelease train
@ai-markdown/vueVue 3.5 renderer — components, scoped slots, SSR/hydration and smooth composablesRelease train; exact core and engine dependencies
@ai-markdown/react-mantineMantine UI bindings — themed typography, code-highlight tabs, Mermaid, color-scheme wiringRelease train; compatible React 3.x peer
@ai-markdown/engineFramework-agnostic engine — incremental parsing, LaTeX preprocessing, plugin pipeline, cross-chunk registryRelease train; pinned exactly by shared core and adapters
@ai-markdown/remark-mark-highlightremark plugin for ==mark== highlight syntaxIndependent semver
React^19.0.0 (react, react-dom peer dependencies)
KaTeX^0.16 or ^0.17 (optional peer — only if you render math)
Node^20.19.0 || >=22.12.0 (engines.node)
Module formatsESM and CJS, TypeScript types for both, sideEffects declared
RuntimesBrowser, Node, edge/workers; server rendering via renderToString, and the bundle keeps its "use client" directive for React Server Components apps — see Streaming & performance
BundlingESM/CJS artifacts and declared side effects; selecting fewer plugins disables their pipeline behavior, but does not guarantee their dependencies disappear from the bundle
Terminal window
# npm
npm install @ai-markdown/react
# pnpm
pnpm add @ai-markdown/react
# yarn
yarn add @ai-markdown/react

The React adapter declares both @ai-markdown/core and @ai-markdown/engine as ordinary dependencies, pinned to the same train version when packed (3.0.0 in this checkout). Applications install @ai-markdown/react; the package manager resolves the shared layers automatically. Core owns sessions, planning and coordination, while engine owns parsing and tree algorithms. Adapter authors may depend on these layers directly and should keep their versions aligned. Exact pins reduce version mismatch; they do not guarantee a single module instance across arbitrary nested installations.

{
"react": "^19.0.0",
"react-dom": "^19.0.0",
"katex": "^0.16.0 || ^0.17.0"
}

katex is optional. For a new application using the math examples, also run pnpm add react@^19 react-dom@^19 katex; declare KaTeX directly when importing its CSS.

For LaTeX math rendering, include the KaTeX stylesheet:

import 'katex/dist/katex.min.css';

katex is declared as an optional peer dependency — by this package and by @ai-markdown/engine, which owns the rehype-katex pipeline step. It ships transitively via rehype-katex, so a hoisted installation may expose the import transitively. Declare it in your own app when importing its CSS, so resolution does not depend on hoisting or installer configuration:

Terminal window
npm install katex

Skip the install only if you have no import 'katex/…' calls in your app and don’t render math.

For the built-in default typography, include the typography CSS:

import '@ai-markdown/react/typography/default.css';
// or import all typography variants at once:
import '@ai-markdown/react/typography/all.css';
import AIMarkdown from '@ai-markdown/react';
import 'katex/dist/katex.min.css';
import '@ai-markdown/react/typography/default.css';
function App() {
return <AIMarkdown content="Hello **world**! Math: $E = mc^2$" />;
}
function StreamingChat({ content, isStreaming }: { content: string; isStreaming: boolean }) {
return <AIMarkdown content={content} streaming={isStreaming} colorScheme="dark" />;
}

All configuration is flat props resolved once against shipped defaults. An explicitly passed prop (v != null) overrides the shipped default; an absent prop falls to the shipped default. Passing null counts as absent — this guards against serialization boundaries (RSC, persistence) materializing “not passed” as null and punching through defaults.

The table below is also the prop-name registry: flat props share one namespace across the React adapter and every wrapper layer, so wrapper authors must check it — plus the wrappers they extend (e.g. mantine adds codeBlock) — before naming a new prop. A collision is a compile error at the extends site for TS consumers but a silent override for plain-JS consumers.

PropTypeDefaultDescription
contentstring(required)Raw markdown content to render.
streamingbooleanfalseWhether content is actively being streamed (e.g. from an LLM).
streamingCursorComponentTypeundefinedStreaming cursor slot. While streaming === true, the given component is rendered after the markdown content and unmounted when streaming stops. Pass the exported AIMarkdownStreamingCursor for the built-in inline cursor. Compared by identity — define at module scope. Definition-aware: while a footnote definition streams, the cursor follows the text into its footer entry; it hides for tails it cannot truthfully point at (a streaming link-reference definition, which renders nothing; a definition whose footer entry lives in another chunk under cross-chunk coordination).
fontSizenumber | string'0.9375rem'Base font size. Numbers are treated as pixels.
variantAIMarkdownVariant'default'Typography variant name.
colorSchemeAIMarkdownColorScheme'light'Color scheme name ('light', 'dark', or custom).
metadataTMetadataundefinedArbitrary data passed to custom components via a dedicated context. Deliberately never stabilized by the library — stabilization is the consumer’s responsibility.
contentPreprocessorsAIMDContentPreprocessor[]undefinedAdditional preprocessors run after the built-in LaTeX preprocessor. An optional createRemendPreprocessor() factory (streaming tail repair — unterminated **bold/`code render styled mid-stream) ships with the package; its effect is opt-in; actual bundle elimination depends on the emitted package and consumer bundler.
customComponentsAIMarkdownCustomComponentsundefinedreact-markdown component overrides for specific HTML elements.
TypographyAIMarkdownTypographyComponentDefaultTypographyTypography wrapper component.
ExtraStylesAIMarkdownExtraStylesComponentundefinedOptional extra style wrapper rendered between typography and content.
documentIdstringauto via useId()Stable id for the logical markdown document this <AIMarkdown> is rendering. Used as the id namespace for clobberable attributes (id, hash hrefs) so two documents on the same page do not cross-link (footnote [^1] in message A won’t scroll to [^1] in message B). When one document is split into chunks rendered by multiple <AIMarkdown> instances, pass the SAME documentId to every chunk so prefixes align. The value is passed through encodeURIComponent before being injected into HTML attributes, so any string is safe (React’s useId() output, your own opaque ids, user-supplied UUIDs — even ill-formed UTF-16 from a string truncated mid-emoji, which is hashed into the prefix and warns in dev builds). Long ids (>16 chars, e.g. UUIDs) are hashed via MurmurHash3 to a short Base62 form inside the rendered id="…"/href="#…" prefix only to keep HTML compact; the documentId exposed by useAIMarkdownDocument() and registry keying via useDocumentRegistry stay raw, so deep linking and any consumer code reading documentId are unaffected.
documentIndexnumbermount orderThis chunk’s position in the DOCUMENT, for instances sharing a documentId under <AIMarkdownDocuments>. Cross-chunk state (footnote numbering, which chunk renders the aggregate footer) follows registration order, which defaults to mount order — correct when chunks mount once in document order. Pass a stable ordinal when chunks can mount out of order or remount (a virtualized transcript that unmounts messages scrolled out of view re-registers them at the end when they scroll back). Ignored outside <AIMarkdownDocuments>. See Cross-chunk Coordination.
urlTransformUrlTransform | nulldefaultUrlTransformOverride the URL allowlist applied to href, src, and similar attributes. The default mirrors GitHub: http, https, irc, ircs, mailto, xmpp. Pass a function defined at module scope (or memoized) to permit additional schemes — see Custom URL Schemes and Sanitization.
sanitizeSchemaSanitizeSchemalibrary defaultOverride the rehype-sanitize schema. Build with extendSanitizeSchema so the library’s cross-chunk tag and KaTeX className allowlists survive — hand-rolling silently drops them.
enginePluginsreadonly AIMarkdownEnginePlugin[]defaultEnginePluginsSealed engine-plugin selection — accepts React-exported plugin objects from @ai-markdown/react/plugins only. Absent → all five shipped plugins; passing an array replaces the set wholesale. See Engine Plugins.
blockMemobooleantrueBlock-level memoization. Output-invariant in standalone rendering — flipping it changes no rendered byte; under <AIMarkdownDocuments> it is the path cross-chunk coordination runs on (false leaves cross-chunk refs literal). See Behavior Props.
incrementalParsebooleantruePrefix-freeze incremental parsing for streaming. Output-invariant; effective only while blockMemo is true. See Behavior Props.
preserveOrphanReferencesbooleantrueProtect orphan footnote/link definitions in incomplete streaming documents. Affects output. See Behavior Props.

Optional pipeline features are selected through the enginePlugins prop, which accepts sealed plugin objects exported from the @ai-markdown/react/plugins subpath. All five are enabled by default.

PluginDescription
highlight==Highlight== syntax support
definitionListDefinition list syntax (PHP Markdown Extra)
removeCommentsStrip HTML comments
smartypantsTypographic substitutions: curly quotes, em-dashes (--), ellipses (...)
panguAuto-insert spaces between CJK and half-width characters
import AIMarkdown from '@ai-markdown/react';
import { highlight, smartypants } from '@ai-markdown/react/plugins';
const PLUGINS = [highlight, smartypants]; // module scope — stable reference
<AIMarkdown content={markdown} enginePlugins={PLUGINS} />;

Passing an array replaces the selection wholesale (array-atomic semantics) — the example above enables only highlight and smartypants, disabling the other three. The recommended “turn one off” idiom:

import { defaultEnginePlugins, pangu } from '@ai-markdown/react/plugins';
const PLUGINS = defaultEnginePlugins.filter((p) => p !== pangu);

Rules worth knowing:

  • Omitting enginePlugins means defaultEnginePlugins (all five).
  • Each plugin’s position in the produced chain comes from its internal stage metadata; the order of your array is irrelevant. Duplicates are deduplicated with a dev warning.
  • The set is sealed: only engine constructs plugins (the incremental engine’s boundary scanner must know every construct’s syntax; open injection would void its verification record). Third-party content extension stays open through contentPreprocessors + customComponents.
  • Plugin objects are not serializable. For remote-config scenarios, store plugin.name strings (typed as AIMarkdownEnginePluginName) and map them back to the exported singletons at the edge.
  • The prop is deep-equal-stabilized as a backstop, but an inline array still pays one comparison per render — define the array at module scope.

Three flat boolean props control engine behavior:

PropTypeDefaultDescription
blockMemobooleantrueEnables block-level memoization: the renderer splits each document into per-block units and memoizes each block’s React subtree by source identity, so unchanged blocks skip toJsxRuntime and React reconcile work during streaming. Output is byte-identical to the disabled path in standalone rendering. Cross-chunk coordination (<AIMarkdownDocuments>) is wired through this path only — with blockMemo={false} a wrapped chunk renders as if standalone (cross-chunk references stay literal). Set blockMemo={false} as an escape hatch for debugging standalone documents.
incrementalParsebooleantruePrefix-freeze incremental parsing for streaming: when content grows by appends, the renderer freezes the stable document prefix at a verified-safe boundary and re-parses only the tail (83–94% less pipeline stage time on the benchmark payloads; footnotes and cross-chunk documents splice too). Output is deep-equal to a full parse — enforced by a per-frame splice-equivalence test suite. Effective only while blockMemo is true.
preserveOrphanReferencesbooleantrueProtects orphan [^x]: … footnote definitions from being silently dropped by mdast-util-to-hast when no matching [^x] reference exists. Useful for streamed content where the reference may arrive in a later chunk. Inside <AIMarkdownDocuments>, the wrapper’s preserveOrphanReferences prop overrides this prop unconditionally.
<AIMarkdown content={markdown} blockMemo={false} incrementalParse={false} />

When a single logical markdown document is split across multiple <AIMarkdown> instances (chunked streaming for chat UIs, etc.), wrap them in <AIMarkdownDocuments> and pass the SAME documentId to every chunk to coordinate footnotes, link references, and image references across chunks:

import AIMarkdown, { AIMarkdownDocuments } from '@ai-markdown/react';
<AIMarkdownDocuments>
{message.chunks.map((c, i) => (
<AIMarkdown key={i} content={c} documentId={message.id} />
))}
</AIMarkdownDocuments>;

Without the wrapper, each <AIMarkdown> is independent — its references resolve only within its own content (current standalone behavior).

Chunks that mount out of order (virtualized lists)

Section titled “Chunks that mount out of order (virtualized lists)”

Cross-chunk state — footnote numbering, and which chunk renders the aggregate footer — follows the order chunks register in, which by default is the order they mount. That is correct as long as each chunk mounts once, in document order.

A virtualized transcript breaks that assumption: a message scrolled out of view unmounts, and scrolling back re-registers it after the chunks that stayed mounted — so footnotes renumber and the footer moves. Pass documentIndex (any stable per-chunk ordinal — the message’s index in your list) and registration order stops depending on mount order:

<AIMarkdownDocuments>
{message.chunks.map((c, i) => (
<AIMarkdown key={i} content={c} documentId={message.id} documentIndex={i} />
))}
</AIMarkdownDocuments>

The prop is optional and changes nothing when every chunk mounts once in order, so existing code needs no update.

PropTypeDefaultDescription
preserveOrphanReferencesbooleantrueControls orphan-reference protection for every chunk under this wrapper. Unconditionally overrides each chunk’s preserveOrphanReferences prop. Does not gate cross-chunk coordination itself (that’s gated by wrapper + documentId).
smoothTurnTakingbooleantrueWrapper-level switch for smooth-stream turn-taking: when true, <AIMarkdownSmoothStream> chunks sharing this documentId type one at a time in mount order. false lets every chunk pace independently. See smooth streaming → turn-taking.
childrenReactNode-The <AIMarkdown> instances to coordinate. Nesting <AIMarkdownDocuments> inside another <AIMarkdownDocuments> throws.

Returns the cross-chunk Registry for the given documentId, or null when called outside <AIMarkdownDocuments> or when documentId is empty. The Registry shape is exported and stable across minor versions — use it when writing typed helpers that operate on the cross-chunk registry directly.

import { useDocumentRegistry, type Registry } from '@ai-markdown/react';
function MyHelper({ documentId }: { documentId: string }) {
const registry: Registry | null = useDocumentRegistry(documentId);
// null when no <AIMarkdownDocuments> ancestor — treat as "run standalone".
}

By default <AIMarkdown> only renders links and images whose URLs use the standard set of safe protocols (http, https, irc, ircs, mailto, xmpp). Anything else — javascript:, data:, or your own myapp:// — is stripped. This protects against XSS in LLM-generated markdown but also means private application schemes are unreachable without configuration.

Sanitization runs in two independent gates (defense in depth):

  1. rehype-sanitize schema — runs first, inside the rehype plugin chain, and drops the URL when the protocol is not in the schema’s per-attribute allowlist (protocols.href, protocols.src, protocols.cite).
  2. urlTransform — runs second, at render time during the hast traversal, on every URL-bearing attribute, that survived the schema; the default transform returns an empty string for a disallowed URL. A custom transform may also return null or undefined to omit the attribute. Called per-attribute with the attribute name ('href' / 'src' / …) so key-aware transforms can discriminate (e.g. allow a scheme on href but not on src to block tracker pixels).

For a private scheme to render, both gates must permit it. Allowing only one is the most common pitfall.

Cross-chunk symmetry. When <AIMarkdown> instances are wrapped in <AIMarkdownDocuments>, link/image references resolved across chunks (chunk A defines [evil]: …, chunk B writes [click][evil]) go through both gates as well — the same urlTransform and sanitizeSchema you pass to <AIMarkdown> apply at render time. The per-attribute key ('href' vs 'src') is honored: a key-aware policy that permits a scheme on <a> but not <img> will produce identical behavior whether the reference is in-chunk or cross-chunk.

Define both gates at module scope so their reference identity is stable across renders (this keeps the per-block memo cache warm):

import AIMarkdown, { defaultUrlTransform, extendSanitizeSchema, type UrlTransform } from '@ai-markdown/react';
// Gate 2: compose with the default so https/mailto/etc. still work.
const ALLOWED = /^myapp:/i;
const URL_TRANSFORM: UrlTransform = (url, key, node) =>
key === 'href' && ALLOWED.test(url) ? url : defaultUrlTransform(url, key, node);
// Gate 1: allow the application scheme for links. Images keep their default policy.
const SCHEMA = extendSanitizeSchema((s) => {
s.protocols!.href!.push('myapp');
});
function App() {
return <AIMarkdown content={markdown} urlTransform={URL_TRANSFORM} sanitizeSchema={SCHEMA} />;
}

extendSanitizeSchema((draft) => Schema | void)

Section titled “extendSanitizeSchema((draft) => Schema | void)”

Hands you a deep clone of the library’s default sanitize schema. Mutate it freely (the original singleton is never touched) or return a replacement object. Library invariants — cross-chunk coordination tags (cross-chunk-link, cross-chunk-image, footnote-sup), the KaTeX math-inline / math-display className allowlist, the <mark> allowance — survive untouched. Hand-rolling a schema that doesn’t spread these invariants silently breaks coordinated rendering, which is why the helper is the recommended path.

const SCHEMA = extendSanitizeSchema((s) => {
(s.tagNames ??= []).push('my-widget'); // add a tag
s.protocols!.href!.push('myapp'); // permit a protocol
(s.attributes ??= {})['my-widget'] = ['dataId', 'dataMode']; // allow attributes
// No `return` needed — mutate-only is fine.
});

Footguns (also documented in JSDoc):

  • Returning null is treated like returning nothing (the mutated draft is used).
  • Reassigning the local parameter (s = { ... }) does NOT replace the draft — JS only rebinds the local. Either mutate the original or return an explicit value.
  • Throwing inside the modifier propagates uncaught. Usually fine because the helper is called once at module load.

Both urlTransform and sanitizeSchema participate in the per-block memo cache, but they are stabilized asymmetrically:

  • urlTransform is tracked by identity only. A new function reference every render flushes the cache. Callers MUST supply a stable reference (module scope or useMemo).
  • sanitizeSchema is tracked by identity AND additionally stabilized internally via a deep-equal safety net (useStableValue). An inline-but-deep-equal schema still works, just with a one-time deep compare on each render — cheaper than a cache flush but not free.

Why the asymmetry: function identity can’t be deep-compared (two closures with identical bodies are always non-equal), so for urlTransform only the call-site can produce a stable reference. sanitizeSchema is plain data, so a deep compare is meaningful and serves as a guardrail for callers who forget the module-scope rule.

// 🚫 Anti-pattern — `urlTransform` is recreated every render and discards
// the entire markdown cache. `sanitizeSchema` would too without the
// internal deep-equal safety net, but you still pay the deep-compare cost.
<AIMarkdown
urlTransform={(url, k, n) => /* … */}
sanitizeSchema={extendSanitizeSchema((s) => /* … */)}
/>
// ✅ Stable — both refs are minted once at module scope.
const URL_TRANSFORM = (url, k, n) => /* … */;
const SCHEMA = extendSanitizeSchema((s) => /* … */);
<AIMarkdown urlTransform={URL_TRANSFORM} sanitizeSchema={SCHEMA} />

In development the library will console.warn after detecting 3+ identity flips on either prop. The warning is dead-code-eliminated in production builds. Define both values at module scope, or memoize with useMemo if they depend on state.

Regex Escaping for + / - / . in Scheme Names

Section titled “Regex Escaping for + / - / . in Scheme Names”

Scheme names can contain +, -, and .. Escape + and . when matching them literally; a hyphen is literal outside a character class. Use /^web\+app:/i for web+app:. The unescaped /^web+app:/i instead matches webapp:, webbapp:, and further repetitions of b.

extendSanitizeSchema hands the modifier a deep clone of the library default. That makes the helper itself the cleanest introspection path — no separate export of the singleton is needed:

extendSanitizeSchema((s) => {
console.log('default sanitize schema:', s);
});

Why no direct sanitizeSchema export? Because the obvious extension pattern — { ...sanitizeSchema, … } — is a shallow spread. Nested arrays (protocols.href, attributes.a, ancestors.*, …) stay aliased to the singleton; a mutation would target shared nested data. The engine singleton is now deeply frozen, so such writes can throw instead of extending it. A deep clone gives each customization its own mutable arrays. extendSanitizeSchema always works on a deep clone, so this class of bug is impossible by construction.

API Stability of UrlTransform and SanitizeSchema

Section titled “API Stability of UrlTransform and SanitizeSchema”

Both prop types track their respective upstream packages — UrlTransform follows react-markdown’s shape and SanitizeSchema follows rehype-sanitize’s. They may evolve with those packages’ major versions. Hand-construct schemas via the helpers (rather than typing your own from scratch) and you’ll inherit any upstream-driven changes automatically.

State is split across five per-system contexts. Each narrow hook subscribes to exactly one system and re-renders only when that system changes — a streaming flip no longer wakes every consumer. All throw if called outside the provider boundary (except useAIMarkdownMetadata, which returns undefined when no metadata was provided).

HookReturns
useAIMarkdownState(){ streaming, …extension state groups }
useAIMarkdownTheme(){ fontSize, variant, colorScheme }
useAIMarkdownDocument(){ documentId, documentIdExplicit, clobberPrefix }
useAIMarkdownBehaviors(){ blockMemo, incrementalParse, preserveOrphanReferences, …wrapper groups }
useAIMarkdownMetadata<TMetadata>()TMetadata | undefined
import { useAIMarkdownState, useAIMarkdownTheme } from '@ai-markdown/react';
function CustomCodeBlock({ children }: PropsWithChildren) {
const { streaming } = useAIMarkdownState();
const { colorScheme } = useAIMarkdownTheme();
if (streaming) {
return <pre className={`streaming ${colorScheme}`}>{children}</pre>;
}
return <pre className={colorScheme}>{children}</pre>;
}

Field reference:

FieldHookTypeDescription
streaminguseAIMarkdownState()booleanWhether content is being streamed.
fontSizeuseAIMarkdownTheme()stringResolved CSS font-size value.
variantuseAIMarkdownTheme()AIMarkdownVariantActive typography variant.
colorSchemeuseAIMarkdownTheme()AIMarkdownColorSchemeActive color scheme.
documentIduseAIMarkdownDocument()stringStable id for the logical markdown document — caller-supplied or auto-generated via useId().
documentIdExplicituseAIMarkdownDocument()booleanWhether documentId was explicitly supplied by the caller (vs. auto-generated). Internal coordination signal — useDocumentRegistry uses it so an auto-generated id never opts a standalone chunk into cross-chunk coordination. Most custom components can ignore this.
clobberPrefixuseAIMarkdownDocument()stringURI-safe id prefix derived from documentId (with MurmurHash3 → Base62 shortening applied for >16-char ids), used by every clobberable HTML attribute (id=… / href="#…"). Read this from the hook rather than recomputing locally when writing components that emit anchors — the prefix’s exact byte form is not part of the stability contract and may shift across versions.
blockMemo / incrementalParse / preserveOrphanReferencesuseAIMarkdownBehaviors()booleanThe resolved behavior switches — same names as the flat props.
const { document, metadata, state, theme, behaviors } = useAIMarkdown();

Subscribes to all five contexts and re-renders on ANY change — including every streaming flip. It serves teaching code and low-frequency components; performance-sensitive components should use the narrow hooks.

Read application data from the metadata context. The hook returns TMetadata | undefined; its generic is a compile-time assertion and cannot verify which component supplied the value.

import { useRef, type PropsWithChildren } from 'react';
import { useAIMarkdownMetadata, type AIMarkdownMetadata } from '@ai-markdown/react';
interface MyMetadata extends AIMarkdownMetadata {
onCopyCode: (source: string) => void;
}
function CustomCodeBlock({ children }: PropsWithChildren) {
const preRef = useRef<HTMLPreElement>(null);
const metadata = useAIMarkdownMetadata<MyMetadata>();
return (
<div>
<button
type="button"
onClick={() => {
metadata?.onCopyCode(preRef.current?.textContent ?? '');
}}
>
Copy
</button>
<pre ref={preRef}>{children}</pre>
</div>
);
}

This small renderer extracts the visible code text from the actual <pre> and keeps the button outside it. String(children) would stringify React elements rather than recover their code. For transformed displays, preserve original source from the hast node instead; the custom component guide provides that fuller recipe.

Metadata is passed through without a deep-equality wrapper. Reuse a stable object when values have not changed; use a new object when reactive metadata changes. A stable container holding callbacks or an external store is useful for high-frequency application data, but mutating a ref alone does not notify a React view.

Returns a referentially stable version of value. On each render the new value is deep-compared (via lodash/isEqual) against the previous one. If they are structurally equal, the previous reference is returned, preventing unnecessary re-renders in downstream useMemo/useEffect consumers.

import { useStableValue } from '@ai-markdown/react';
const stableConfig = useStableValue(config);
// stableConfig keeps the same reference as long as config is deep-equal.

The stability firewall used internally, exported for wrapper authors. Returns a referentially stable version of record according to a per-key AIMarkdownStabilityPolicy table:

  • DEEP_EQUAL — restore the previous reference when the new value is deep-equal (plain-data props).
  • WARN_ONLY — pass through, but warn in dev after repeated identity flips (functions/components, where deep comparison is meaningless).
  • PASS_THROUGH — declared exemption, no stabilization (e.g. metadata).

A wrapper builds a table only for the object props it terminates itself (e.g. mantine’s codeBlock); props forwarded to <AIMarkdown> ride the React adapter’s firewall untouched.

import { useStableRecord, AIMarkdownStabilityPolicy, type AIMarkdownStabilityTable } from '@ai-markdown/react';
const TABLE: AIMarkdownStabilityTable<{ panel: Partial<PanelOptions> | undefined }> = {
panel: AIMarkdownStabilityPolicy.DEEP_EQUAL,
};
const stable = useStableRecord({ panel }, TABLE);

The React adapter exports two stackable Providers — AIMarkdownBehaviorsProvider and AIMarkdownStateProvider — so wrappers and applications can transport their own extension groups through the React adapter’s contexts. Stack the Provider outside <AIMarkdown>; consumers still see exactly one context:

import { useMemo } from 'react';
import AIMarkdown, { AIMarkdownBehaviorsProvider, type AIMarkdownBehaviorGroups } from '@ai-markdown/react';
const NO_GROUPS: AIMarkdownBehaviorGroups = Object.freeze({});
function MyMarkdown({ panel, ...rest }: MyMarkdownProps) {
// Absent prop → contribute NO group (an outer app-level Provider's
// `panel` group then stays visible); present prop wins via inner-wins.
const groups = useMemo<AIMarkdownBehaviorGroups>(() => (panel != null ? { panel } : NO_GROUPS), [panel]);
return (
<AIMarkdownBehaviorsProvider value={groups}>
<AIMarkdown {...rest} />
</AIMarkdownBehaviorsProvider>
);
}
  • Built-in React prop keys are locked. Behaviors (blockMemo, incrementalParse, preserveOrphanReferences) and state (streaming) cannot be injected from outside — type-forbidden, unconditionally overwritten by the prop-resolved values at the innermost merge, and warned about in dev.
  • Multi-level wrappers stack naturally; for a duplicated group key the inner layer wins.
  • AIMarkdownStateProvider carries extension lifecycle states (aborted, reasoning, tool-call-in-progress, …). Group members must be message-lifecycle frequency — frame-rate data (per-token progress etc.) still goes through metadata’s stable-container pattern.
  • Apply group defaults inside your wrapper’s narrow hook exactly once (the pattern behind mantine’s useMantineCodeBlockOptions()); bare ?? fallbacks at multiple read sites will drift.

Group keys share one namespace per context (behaviors, state) across every wrapper layer and the application — a duplicated key resolves by inner-wins silently, so this registry is the collision governance, the same way the props table above governs flat prop names. Wrapper packages: register your keys here via PR before shipping. Applications: use app-scoped names (chatPanel, not panel) to stay clear of future wrapper keys.

Group keyContextOwner
codeBlockbehaviors@ai-markdown/react-mantine

Reservation policy: the React adapter will not promote a registered group key into a core-locked key (the never-typed lock) within a stable major release line (prerelease APIs can still change) — promotion retroactively breaks every downstream compile that used the key, so it is by definition a major-version change.

The <AIMarkdown> component wraps its content in a typography component that controls font size, variant, and color scheme.

The built-in DefaultTypography renders a <div> with CSS class names for the active variant and color scheme:

<div class="aim-typography-root default light" style="width: 100%; font-size: 0.9375rem">
<!-- markdown content -->
</div>

Import the corresponding CSS to activate styles:

import '@ai-markdown/react/typography/default.css';

All default-variant styles are driven by CSS custom properties declared on .aim-typography-root.default. Spacing, font-size, and heading tokens are anchored to --aim-font-size-root (injected by the renderer from the fontSize prop), so those dimensions scale with fontSize; radius constants, border widths, and unitless values follow their own declarations. To customize, override any token in your own stylesheet:

.aim-typography-root.default {
--aim-spacing-md: calc(var(--aim-font-size-root) * 1.2); /* roomier paragraphs */
--aim-h1-font-size: calc(var(--aim-font-size-root) * 2.5); /* bigger H1 */
--aim-font-weight-strong: 600; /* lighter headings + th */
--aim-color-anchor: #ff6b6b; /* red links */
}
GroupTokensNotes
Spacing--aim-spacing-{xs,sm,md,lg,xl}calc(var(--aim-font-size-root) * k) where k ∈ {0.625, 0.75, 1, 1.25, 1.5}
Font size--aim-font-size-{xs,sm,md,lg,xl}k ∈ {0.75, 0.875, 1, 1.125, 1.25}
Heading sizes--aim-h{1..6}-font-sizeMultipliers mirror Mantine’s heading scale ({2.125, 1.625, 1.375, 1.125, 1, 0.875})
Heading meta--aim-h{1..6}-line-height, --aim-h{1..6}-font-weightline-heights are unitless; weights default to var(--aim-font-weight-strong)
Weight--aim-font-weight-strongShared by all headings and <th>. Default 700.
KaTeX--aim-katex-font-sizeDefaults to var(--aim-font-size-root), so formulas stay at the component-root size regardless of parent context (blockquote, heading). Override to 1em if you want parent-relative.
Misc--aim-line-height, --aim-radius-sm, --aim-font-family-{monospace,headings}Unitless / rem / font-stack constants.
Color (light)--aim-color-{text,dimmed,anchor,border,code-bg,code-text,blockquote-bg,mark-bg,mark-text}Declared on .aim-typography-root.light; dark variants on .aim-typography-root.dark.

Stability contract: the names and roles of these tokens follow semver. The exact default values (multipliers, colors) may shift under minor bumps as the visual design evolves — override the token if you need a specific value to be locked.

Replace the typography wrapper by passing a custom component. The style prop carries CSS custom properties injected by the React renderer — merge it onto your root element so that descendant CSS can reference these variables:

import type { AIMarkdownTypographyProps } from '@ai-markdown/react';
function MyTypography({ children, fontSize, variant, colorScheme, style }: AIMarkdownTypographyProps) {
return (
<div className={`my-markdown ${colorScheme}`} style={{ fontSize, ...style }}>
{children}
</div>
);
}
<AIMarkdown content={markdown} Typography={MyTypography} />;

The React renderer injects the following CSS custom properties via the Typography style prop:

VariableValuePurpose
--aim-font-size-rootfontSize propAbsolute font-size anchor for the component instance. Inner CSS can use var(--aim-font-size-root) to bypass em compounding in deeply nested markdown structures (e.g. code inside blockquotes).

Why --aim-font-size-root? Markdown content frequently nests elements that use relative em units — blockquotes, lists, code blocks. Each nesting level compounds the effective size: a 0.875em code span inside a 1.125em blockquote resolves to 0.984em of the parent, not 0.875em of the root. This variable provides a stable, absolute reference that inner CSS rules can use to opt out of compounding when a fixed size is needed.

The built-in default variant already consumes this variable — all of its spacing, font-size, and heading tokens are defined as calc(var(--aim-font-size-root) * k), so changing the fontSize prop on <AIMarkdown> scales the root-anchored dimensions, without changing independent rem/px constants. See Customization tokens above for the full surface.

The ExtraStyles prop accepts a component rendered between the typography wrapper and the markdown content. Useful for injecting additional CSS scope or theme providers:

import type { AIMarkdownExtraStylesProps } from '@ai-markdown/react';
function MyExtraStyles({ children }: AIMarkdownExtraStylesProps) {
return <div className="my-extra-scope">{children}</div>;
}
<AIMarkdown content={markdown} ExtraStyles={MyExtraStyles} />;

Override the default renderers for specific HTML elements using the customComponents prop. This maps directly to react-markdown’s Components type:

import type { AIMarkdownCustomComponents } from '@ai-markdown/react';
const components: AIMarkdownCustomComponents = {
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
img: ({ src, alt }) => <img src={src} alt={alt} loading="lazy" />,
};
<AIMarkdown content={markdown} customComponents={components} />;

Update content with the full accumulated string and set streaming from the source lifecycle. The flag reaches useAIMarkdownState() consumers and controls cursor mounting; it does not select a separate Markdown grammar. Incremental parsing is governed by the behavior switches and the append/safety gates.

import { AIMarkdownStreamingCursor } from '@ai-markdown/react';
<AIMarkdown
content={accumulatedMarkdown}
streaming={requestStatus === 'streaming'}
streamingCursor={AIMarkdownStreamingCursor}
/>;

A cursor belongs outside the source string. Appending a decorative character to Markdown breaks the append-only relationship between frames and can corrupt code or math text. The built-in cursor uses a DOM overlay and hides for unsupported tails such as code, math, and images.

For paced presentation, replace the renderer with AIMarkdownSmoothStream, or pass useSmoothStream({ content, streaming }) into a custom wrapper. The source may finish before the visible text has drained; the returned streaming flag remains true during that drain. Existing text snaps on mount, replacements snap after the sync effect, and only real backlog rounds produce drain callbacks.

Use one renderer for a normal transport stream. Multiple logical Markdown chunks can share an explicit document id under AIMarkdownDocuments, with block memoization enabled. The wrapper coordinates references; it does not reconnect fences, tables, or paragraphs split at arbitrary token boundaries.

See the full chat example, smooth streaming, and streaming performance for framing, cancellation, lifecycle, and cache behavior.

The metadata prop lets you pass arbitrary data to deeply nested custom components without prop drilling. Metadata is stored in a separate React context from the render state, so updating metadata does not cause re-renders in components that only read render state (like the core MarkdownContent).

interface ChatMetadata {
messageId: string;
onCopyCode: (code: string) => void;
onRegenerate: () => void;
}
<AIMarkdown<ChatMetadata>
content={markdown}
metadata={{
messageId: msg.id,
onCopyCode: handleCopy,
onRegenerate: handleRegenerate,
}}
/>;

The rendering pipeline runs a LaTeX preprocessor by default. You can append additional preprocessors that transform the raw markdown string before it enters the remark/rehype pipeline:

import type { AIMDContentPreprocessor } from '@ai-markdown/react';
const stripFrontmatter: AIMDContentPreprocessor = (content) => content.replace(/^---[\s\S]*?---\n/, '');
<AIMarkdown content={markdown} contentPreprocessors={[stripFrontmatter]} />;

Preprocessors run in sequence: built-in LaTeX preprocessor first, then your custom ones in array order.

The component takes one generic type parameter — TMetadata for type-safe metadata:

import AIMarkdown, { type AIMarkdownMetadata } from '@ai-markdown/react';
interface MyMetadata extends AIMarkdownMetadata {
messageId: string;
}
<AIMarkdown<MyMetadata> content={markdown} metadata={{ messageId: '123' }} />;

The metadata hook accepts the matching generic:

const metadata = useAIMarkdownMetadata<MyMetadata>();

Sub-packages extend the flat prop surface instead of a config generic: @ai-markdown/react-mantine’s MantineAIMarkdownProps<TMetadata> extends AIMarkdownProps<TMetadata> adds a codeBlock prop, transports it through AIMarkdownBehaviorsProvider, and asserts the group type exactly once inside its own narrow hook (useMantineCodeBlockOptions()). See Additive Providers above and Extending via a sub-package.

<AIMarkdown>
<AIMarkdownMetadataProvider> // Separate context for metadata
<AIMarkdownProvider> // Per-system contexts: document, state, theme, behaviors
<Typography> // Configurable typography wrapper
<ExtraStyles?> // Optional extra style wrapper
<AIMarkdownContent /> // react-markdown with remark/rehype plugin chain
</ExtraStyles?>
</Typography>
</AIMarkdownProvider>
</AIMarkdownMetadataProvider>
</AIMarkdown>

State is deliberately split across five per-system contexts (document, metadata, state, theme, behaviors) so a change in one system — a metadata callback swap, a streaming flip — only re-renders that system’s subscribers.

  • AIMarkdown — the main component (memoized)
  • AIMarkdownDocuments — optional outer wrapper enabling cross-chunk coordination
  • AIMarkdownStreamingCursor — built-in inline cursor for the streamingCursor slot
  • AIMarkdownSmoothStream<AIMarkdown> plus typewriter pacing (smooth* props); chunks sharing a documentId under <AIMarkdownDocuments> reveal turn-by-turn (one typewriter, one cursor); see apps/docs/content/guides/smooth-streaming.md
  • AIMarkdownBehaviorsProvider — additive transport for wrapper/app behavior groups (stack outside <AIMarkdown>)
  • AIMarkdownStateProvider — additive transport for extension lifecycle-state groups
  • useAIMarkdownState(), useAIMarkdownTheme(), useAIMarkdownDocument(), useAIMarkdownBehaviors(), useAIMarkdownMetadata<T>() — the five narrow hooks
  • useAIMarkdown() — the aggregate (subscribes to all five contexts)
  • useDocumentRegistry()
  • useSmoothStream() — typewriter pacing as a hook; returns a props-shaped { content, streaming, flush } that spreads into any wrapper
  • useDocumentSmoothStream()useSmoothStream plus document turn-taking (waiting reserves an empty slot before input): pass a documentId and, under <AIMarkdownDocuments>, chunks reveal in mount order (one typewriter, one cursor); degrades to plain useSmoothStream without one
  • useStableValue()
  • useStableRecord() — the stability firewall, for wrapper authors
  • defineTheme, defineBehaviors, definePipeline — frozen, typed, reference-stable flat prop fragments (identity + types + Object.freeze, zero logic)
  • createRemendPreprocessor() — opt-in streaming tail-repair factory for contentPreprocessors (effect enabled by including it in the preprocessor array)
  • createSmoothStreamController() — the framework-free pacing core beneath useSmoothStream (no React/DOM dependency); accepts advanced numeric overrides on top of the pacing presets
  • SMOOTH_STREAM_PACING_PRESETS — the frozen parameter bundles behind the three smoothPacing presets
  • defaultUrlTransform — the library’s built-in URL-allowlist transform; compose with this when supplying a custom urlTransform
  • extendSanitizeSchema((draft) => Schema | void) — mutate-and-return factory that produces a sanitize schema from a deep clone of the library default; preserves cross-chunk and KaTeX invariants
  • AIMarkdownStabilityPolicy — policy enum for useStableRecord tables (DEEP_EQUAL / WARN_ONLY / PASS_THROUGH)
  • highlight, definitionList, smartypants, pangu, removeComments — the sealed engine plugin singletons
  • defaultEnginePlugins — all five, the shipped default selection
  • AIMarkdownProps
  • AIMarkdownDocumentsProps
  • AIMarkdownCustomComponents
  • AIMarkdownMetadata
  • AIMarkdownEnginePlugin, AIMarkdownEnginePluginName — sealed plugin type + its name union (the serialization escape hatch)
  • AIMarkdownTypographyProps
  • AIMarkdownTypographyComponent
  • AIMarkdownExtraStylesProps
  • AIMarkdownExtraStylesComponent
  • AIMarkdownVariant
  • AIMarkdownColorScheme
  • AIMDContentPreprocessor, RemendPreprocessorOptions
  • AIMarkdownThemeProps, AIMarkdownBehaviorProps, AIMarkdownPipelineProps — the define* factory input types
  • AIMarkdownStabilityTable — table type for useStableRecord
  • Context payload types: AIMarkdownDocumentInfo, AIMarkdownThemeInfo, AIMarkdownStateCore, AIMarkdownBehaviorsCore, AIMarkdownStateGroups, AIMarkdownBehaviorGroups, AIMarkdownExtensionGroups, AIMarkdownAggregate
  • Streaming cursor types: AIMarkdownStreamingCursorProps, AIMarkdownStreamingIndicatorProps, AIMarkdownStreamingIndicatorComponent
  • Smooth streaming types: AIMarkdownSmoothStreamProps, SmoothStreamController, SmoothStreamOptions, SmoothStreamPacing, SmoothStreamPacingParams, UseSmoothStreamOptions, UseSmoothStreamResult, UseDocumentSmoothStreamOptions
  • UrlTransform, SanitizeSchema — prop-type aliases for the URL handling props (track upstream react-markdown / rehype-sanitize shapes)
  • Cross-chunk registry types: Registry, ChunkData, FootnoteDef, LinkDef, RefRecord, RefKind
GuideWhat it covers
Streaming & performanceBlock memoization, incremental (prefix-freeze) parsing, what to pass while tokens arrive
Smooth streaming<AIMarkdownSmoothStream> typewriter reveal, pacing presets, document turn-taking
Streaming cursorThe overlay cursor that tracks the streaming tail
Cross-chunk coordination<AIMarkdownDocuments>, footnotes and link references across chunks, the registry
URL sanitization & custom schemesThe two-gate model, urlTransform, extendSanitizeSchema
Custom components · Custom typography · Design tokensSwapping renderers, theming, the --aim-* variables
CJK typographyLine breaking, spacing, pangu
Metadata context · TypeScript genericsPassing typed metadata to custom components
Content preprocessorsRewriting the source before it parses
Extending via subpackageBuilding your own UI-kit binding (the mantine package is the reference)
Architecture · BenchmarkHow the packages fit together, measured numbers
Migrating to v2 · Release highlightsOld → new API mapping, what changed per version

Integration checks and implementation boundaries

Section titled “Integration checks and implementation boundaries”

Before adding customization, verify the basic renderer with its typography CSS and KaTeX CSS. Then add one surface at a time: tokens for appearance, customComponents for element behavior, metadata for application data, and a schema/URL transform for an explicitly chosen URL policy. This makes a missing style distinguishable from a parser or sanitizer result.

For streaming, keep component keys stable, preserve the accumulated source, and end the source state on success, cancellation, and failure. Completed blocks may reuse React elements, but their state, context, and external-store subscriptions can still update them. Cache reuse is not a promise that a custom component will never render again.

For coordination, test a late definition and a chunk remount. documentIndex orders the mounted entries; unmounting still removes their contributions. Server rendering and the first hydration frame use local definitions until effects publish shared contributions. Auto-generated ids namespace standalone output and do not opt into coordination.

The default schema removes disallowed tags and attributes before urlTransform; the callback cannot restore an attribute already removed. Cross-chunk references apply the consuming chunk’s policy to their final a or img, including ancestor constraints. Registry selectors expose raw definition URLs, so a custom sidebar must apply its own URL policy before rendering them.

Source owners: src/index.tsx resolves public inputs, MarkdownContent owns rendering and contribution effects, and the engine README describes the syntax layer. The development guide index connects every customization surface to a detailed recipe.

MIT