Override how individual HTML elements are rendered by passing customComponents to <AIMarkdown>. This is the most common extension point — you'll reach for it whenever you need a chat-specific code block, a link that opens in a new tab, an image that lazy-loads, or any other per-element behavior.
import AIMarkdown, { type AIMarkdownCustomComponents } from '@ai-react-markdown/core';
const components: AIMarkdownCustomComponents = {
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
};
<AIMarkdown content={markdown} customComponents={components} />;AIMarkdownCustomComponents is a re-exported alias of the local Markdown-wrapper Components type (a vendored fork of react-markdown's). The available element keys mirror the HTML element names markdown emits: a, blockquote, br, code, em, h1–h6, hr, img, li, ol, p, pre, strong, table, td, th, tr, ul, plus GFM (del, input for task lists), KaTeX wrapper spans, definition lists (dl, dt, dd), and the mark element from ==highlight== syntax.
const components: AIMarkdownCustomComponents = {
img: ({ src, alt, title }) => <img src={src} alt={alt ?? ''} title={title} loading="lazy" decoding="async" />,
};The alt prop can be undefined for unlabeled images — coerce to '' for accessibility.
const components: AIMarkdownCustomComponents = {
a: ({ href, children, ...rest }) => {
const isExternal = href?.startsWith('http://') || href?.startsWith('https://');
return (
<a {...rest} href={href} {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
{children}
</a>
);
},
};const components: AIMarkdownCustomComponents = {
table: ({ children }) => (
<div className="table-scroll-wrapper">
<table>{children}</table>
</div>
),
};GFM task lists emit <input type="checkbox" disabled> by default. Re-render them as live toggles by intercepting the input element:
const components: AIMarkdownCustomComponents = {
input: ({ type, checked, ...rest }) => {
if (type !== 'checkbox') return <input type={type} {...rest} />;
return <input type="checkbox" defaultChecked={checked} {...rest} />;
},
};For full interactivity (persist toggles to state), pair this with Metadata Context so the component reads/writes app state without prop drilling.
import { useAIMarkdownRenderState } from '@ai-react-markdown/core';
function CopyableCode({ children, className }: { children: React.ReactNode; className?: string }) {
const { streaming } = useAIMarkdownRenderState();
const text = String(children).replace(/\n$/, '');
return (
<pre className={className}>
{!streaming && <button onClick={() => navigator.clipboard.writeText(text)}>Copy</button>}
<code>{children}</code>
</pre>
);
}
const components: AIMarkdownCustomComponents = {
pre: ({ children }) => {
// children is typically a single <code> element from a fenced block
return <CopyableCode>{children}</CopyableCode>;
},
};Hiding the copy button while streaming === true is a common touch — copying half-finished code is rarely useful and the button flickering as content grows is distracting.
const components: AIMarkdownCustomComponents = {
h2: ({ children, id }) => (
<h2 id={id}>
{id && (
<a href={`#${id}`} className="heading-anchor">
#
</a>
)}
{children}
</h2>
),
};The id is auto-prefixed by the library's documentId namespace — see Architecture for how multi-document pages avoid id collisions.
customComponents participates in the block-memo cache. The library internally stabilizes it via deep-equal (useStableValue), so an inline object will be tolerated — but the deep compare on every render isn't free. The recommended pattern is module scope or useMemo:
// ⚠️ Re-created every render — internal deep-equal catches it, but pays a deep-compare cost.
<AIMarkdown content={c} customComponents={{ a: MyLink }} />;
// ✅ Stable identity, zero overhead.
const COMPONENTS = { a: MyLink } satisfies AIMarkdownCustomComponents;
<AIMarkdown content={c} customComponents={COMPONENTS} />;The component functions themselves should also be stable. A function MyLink() {…} declaration or a const MyLink = (props) => … at module scope is fine. Defining the function inside the parent's render body recreates it every frame:
// ⚠️ New MyLink reference every render.
function Parent({ content }) {
const MyLink = (props) => <a {...props} target="_blank" />;
return <AIMarkdown content={content} customComponents={{ a: MyLink }} />;
}@ai-react-markdown/mantine ships its own customComponents.pre that powers code highlighting, Mermaid, and JSON pretty-print. Caller-provided components are merged on top of the Mantine defaults — your overrides take precedence:
// Mantine handles <pre>, you handle <a>:
<MantineAIMarkdown content={c} customComponents={{ a: MyLink }} />
// Mantine still handles <pre> — your <a> override doesn't affect it.If you supply pre yourself, you fully replace Mantine's code-block pipeline:
// ⚠️ Disables Mantine's CodeHighlight, Mermaid, JSON pretty-print.
<MantineAIMarkdown content={c} customComponents={{ pre: MyPlainPre }} />That's sometimes what you want (you have your own highlighter) — just be aware of the consequence.
Most custom components receive node as a prop — the hast Element node that this component renders. Use this when you need information beyond the standard HTML attributes. Always optional-chain — node can be undefined for synthetic elements (e.g. nodes emitted by a custom remark plugin without position info, or library-internal placeholder elements), and node.position is itself optional even when node is present.
const components: AIMarkdownCustomComponents = {
code: ({ node, className, children }) => {
const language = className?.replace('language-', '');
const sourceOffset = node?.position?.start?.offset;
// …use language and sourceOffset for analytics, syntax highlighting, etc.
return <code className={className}>{children}</code>;
},
};node.position tracks the source-level location of the element — useful for selective behaviors (e.g. "only show the line number for code blocks in the first 100 chars of the document").
The library namespaces every clobberable attribute (id="…" / href="#…") with a per-document prefix so footnote anchors and hash hrefs don't collide across <AIMarkdown> instances. If your custom component emits its own ids — e.g. anchor links on headings — you should use the same prefix instead of inventing one. Read clobberPrefix from the render state:
import { useAIMarkdownRenderState } from '@ai-react-markdown/core';
function slugify(s: string) {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
const components: AIMarkdownCustomComponents = {
h2: ({ children }) => {
const { clobberPrefix } = useAIMarkdownRenderState();
const text = String(children);
const id = `${clobberPrefix}heading-${slugify(text)}`;
return (
<h2 id={id}>
<a href={`#${id}`} className="heading-anchor">
#
</a>
{children}
</h2>
);
},
};The exact byte form of clobberPrefix (long ids get MurmurHash3-shortened to keep HTML compact) is not part of the stability contract — always read it from render state, never recompute from documentId.
Custom components are called by react-markdown per node. They can use Hooks like any React component — but every code path must reach the same number of Hook calls in the same order. The usual rules-of-Hooks apply:
// ⚠️ Calling a Hook conditionally breaks rules-of-Hooks.
const components: AIMarkdownCustomComponents = {
a: ({ href }) => {
if (!href) return null;
const state = useAIMarkdownRenderState(); // Hook after conditional return
return <a href={href}>{state.colorScheme}</a>;
},
};
// ✅ Hooks first, conditional later.
const components: AIMarkdownCustomComponents = {
a: ({ href }) => {
const state = useAIMarkdownRenderState();
if (!href) return null;
return <a href={href}>{state.colorScheme}</a>;
},
};node is shared across renders when block-memoization caches a block. Mutating it (node.properties.className = …) bleeds into future renders that hit the cache, causing intermittent visual bugs. Treat node as read-only.
Custom components run on every render of their parent block. If a component does expensive work (syntax highlighting, code formatting, large regex), memoize the work by the input(s) that actually drive it:
import { useMemo } from 'react';
const components: AIMarkdownCustomComponents = {
code: ({ children }) => {
const text = String(children);
const highlighted = useMemo(() => expensiveHighlight(text), [text]);
return <code dangerouslySetInnerHTML={{ __html: highlighted }} />;
},
};Note: block-level memoization already caches the React subtree of unchanged blocks by source identity, so a block that doesn't change between renders won't re-invoke its custom component at all. Per-component useMemo matters mainly when the block does change but the expensive sub-computation should be reused (e.g. content changed but the code language didn't).