Render Comark in React
The @comark/react package provides React components for rendering Comark content with full support for custom components, plugins, and streaming.
Installation
pnpm add @comark/reactnpm install @comark/reactyarn add @comark/reactbun add @comark/react<Markdown>
The <Markdown> component is the simplest way to render markdown in React. It handles parsing and rendering automatically.
<Markdown> is an async component. You can also use the <MarkdownDocument> component to handle parsing yourself.import { Markdown } from '@comark/react'
const content = `# Hello World
This is **markdown** with Comark components.
`
export default function App() {
return <Markdown>{content}</Markdown>
}Usage
Pass markdown content via children or the value prop. value accepts a markdown string or a pre-parsed MarkdownDocument:
<Markdown>{content}</Markdown><Markdown value={content} />import type { MarkdownDocument } from 'comark'
export default function Article({ document }: { document: MarkdownDocument }) {
return <Markdown value={document} />
}<Markdown> skips parsing at runtime, but the parser is still bundled because Markdown imports it. To keep the client bundle free of the parser, use <MarkdownDocument> instead.Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Markdown content to parse and render |
value | string | MarkdownDocument | '' | Markdown string or pre-parsed document (alternative to children) |
options | ParserOptions | {} | Parser options (autoUnwrap, autoClose, etc.) |
plugins | ComarkPlugin[] | [] | Array of plugins |
unwrap | boolean | string | string[] | false | Strip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, e.g. "ul li" |
components | Record<string, ComponentType> | {} | Custom React component mappings |
componentsManifest | (name: string) => Promise<Component> | undefined | Dynamic component resolver |
streaming | boolean | false | Enable streaming mode |
caret | boolean | { class: string } | false | Append caret to last text node |
data | Record<string, unknown> | undefined | Runtime values referenced from markdown via :prop="data.path" |
className | string | undefined | CSS class for wrapper element |
options
See ParserOptions for available options.
<Markdown options={{ autoUnwrap: true, autoClose: true }}>
{content}
</Markdown>plugins
See ComarkPlugin for available plugins.
import { Markdown } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
const plugins = [
shiki({
themes: { light: githubLight, dark: githubDark }
})
]
export default function App() {
return <Markdown plugins={plugins}>{content}</Markdown>
}For math and mermaid plugins, also pass the companion components:
import { Markdown } from '@comark/react'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import 'katex/dist/katex.min.css'
export default function App() {
return (
<Markdown
value={markdown}
components={{ Math, Mermaid }}
plugins={[math(), mermaid()]}
/>
)
}components
Use this prop to map custom React components to Comark elements and use them in your markdown.
Create a component
interface AlertProps {
type?: 'info' | 'warning' | 'error' | 'success'
children: React.ReactNode
}
export default function Alert({ type = 'info', children }: AlertProps) {
return (
<div className={`alert alert-${type}`} role="alert">
{children}
</div>
)
}Map the tag to your component
import { Markdown } from '@comark/react'
import Alert from './components/Alert'
import Card from './components/Card'
const components = { alert: Alert, card: Card }
export default function App() {
return <Markdown components={components}>{content}</Markdown>
}Use it in your Markdown
::alert{type="warning"}
This is a warning message!
::componentsManifest
For lazy-loading components on demand. Components are resolved via React.lazy() and wrapped in <Suspense> automatically. Works with both <Markdown> and <MarkdownDocument>:
import { Markdown } from '@comark/react'
const manifest = (name: string) => {
return import(`./components/prose/${name}.tsx`)
}
export default function App() {
return <Markdown componentsManifest={manifest}>{content}</Markdown>
}data
Expose runtime values to markdown authors. Any prop written with a : prefix is resolved against the render context { frontmatter, meta, data, props } when its value isn't valid JSON. See Data Binding for the full scope.
import { Markdown } from '@comark/react'
const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
export default function App() {
return <Markdown value={content} data={{ user }} />
}defineMarkdownComponent
Creates a pre-configured <Markdown> component with default options, plugins, and components baked in.
Usage
Expose your configured component
import { defineMarkdownComponent } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import CustomAlert from './components/CustomAlert'
export const AppMarkdown = defineMarkdownComponent({
name: 'AppMarkdown',
plugins: [
math(),
mermaid(),
shiki({
themes: {
light: githubLight,
dark: githubDark
},
}),
],
components: {
Math,
Mermaid,
alert: CustomAlert,
},
})Use it in your templates
import { AppMarkdown } from './markdown'
export default function App() {
return (
<>
{/* All configuration is already included */}
<AppMarkdown>{content}</AppMarkdown>
{/* Can still override per-instance */}
<AppMarkdown components={{ alert: DifferentAlert }}>
{content}
</AppMarkdown>
</>
)
}Options
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineMarkdownComponent> | undefined | Inherit plugins and components from another component |
name | string | undefined | Component name for debugging |
autoUnwrap | boolean | true | Automatically unwrap single block elements |
autoClose | boolean | true | Auto-close incomplete markdown syntax |
linkify | boolean | true | Auto-convert URL-like text into links |
registerDefaultPlugins | boolean | true | Register default plugins (frontmatter, html, alert, task-list, components, attributes) |
plugins | ComarkPlugin[] | [] | Array of plugins |
components | Record<string, ComponentType> | {} | Custom React component mappings |
className | string | undefined | Additional CSS classes for the wrapper div |
extends
Inherit plugins and components from another component, then layer your own on top:
import { defineMarkdownComponent } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import toc from '@comark/react/plugins/toc'
import math, { Math } from '@comark/react/plugins/math'
import CodeBlock from './components/CodeBlock'
// Base: highlight + shared prose overrides, used everywhere
const BaseMarkdown = defineMarkdownComponent({
name: 'BaseMarkdown',
plugins: [shiki({ themes: { light: githubLight, dark: githubDark } })],
components: { pre: CodeBlock },
})
// Article: extends Base, adds TOC and math
export const ArticleMarkdown = defineMarkdownComponent({
name: 'ArticleMarkdown',
extends: BaseMarkdown,
plugins: [toc({ depth: 3 }), math()],
components: { Math },
})
// Comment: extends Base only, no TOC, no math
export const CommentMarkdown = defineMarkdownComponent({
name: 'CommentMarkdown',
extends: BaseMarkdown,
})Merging behavior
plugins: Arrays are concatenated (config plugins + prop plugins)components: Props override config (prop components take precedence)- Other
options: Props override config
Usage with Next.js App Router
'use client'
import { defineMarkdownComponent } from '@comark/react'
import math, { Math } from '@comark/react/plugins/math'
export const DocsMarkdown = defineMarkdownComponent({
name: 'DocsMarkdown',
plugins: [math()],
components: { Math },
})import { DocsMarkdown } from '@/components/markdown'
export default async function Page({ params }: { params: { slug: string } }) {
const content = await getDocContent(params.slug)
return <DocsMarkdown>{content}</DocsMarkdown>
}<MarkdownDocument>
Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser.
Parsing
Parse your markdown content and pass the document directly in a React Server Component:
import { createMarkdownParser } from 'comark'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MarkdownDocument } from '@comark/react'
import Alert from '@/components/Alert'
const parse = createMarkdownParser()
export default async function DocsPage({ params }: { params: { slug: string } }) {
const markdown = await readFile(join('content', `${params.slug}.md`), 'utf-8')
const document = await parseMarkdown(markdown)
return <MarkdownDocument value={document} components={{ alert: Alert }} />
}Renderer Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | MarkdownDocument | — | Required. The parsed document returned by parseMarkdown() |
components | Record<string, ComponentType> | {} | Custom React component mappings |
componentsManifest | ComponentManifest | undefined | Dynamic component resolver for lazy-loaded components |
streaming | boolean | false | Enable streaming mode |
caret | boolean | { class: string } | false | Append a blinking caret to the last text node |
data | Record<string, unknown> | undefined | Runtime values referenced from markdown via :prop="data.path" |
className | string | undefined | CSS class for the wrapper <div> |
defineMarkdownDocumentComponent
Creates a pre-configured <MarkdownDocument> with baked-in component mappings.
Setup
Expose your configured renderer
import { defineMarkdownDocumentComponent } from '@comark/react'
import Alert from './components/Alert'
import CodeBlock from './components/CodeBlock'
export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
name: 'ArticleMarkdownDocument',
components: {
alert: Alert,
pre: CodeBlock,
},
})Page integration
import { parseMarkdown } from 'comark'
import { ArticleMarkdownDocument } from '@/markdown'
export default async function Page({ params }: { params: { slug: string } }) {
const markdown = await getContent(params.slug)
const document = await parseMarkdown(markdown)
return <ArticleMarkdownDocument value={document} />
}Renderer Options
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineMarkdownDocumentComponent> | undefined | Inherit component mappings from another renderer |
name | string | undefined | Component name for debugging |
components | Record<string, ComponentType> | {} | Custom React component mappings |
className | string | undefined | Additional CSS classes for the wrapper div |
Inheritance
Inherit component mappings from another renderer, then layer your own on top:
import { defineMarkdownDocumentComponent } from '@comark/react'
import CodeBlock from './components/CodeBlock'
import ProseA from './components/ProseA'
import Alert from './components/Alert'
import CommentAlert from './components/CommentAlert'
const BaseMarkdownDocument = defineMarkdownDocumentComponent({
name: 'BaseMarkdownDocument',
components: { pre: CodeBlock, a: ProseA },
})
export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
name: 'ArticleMarkdownDocument',
extends: BaseMarkdownDocument,
components: { alert: Alert },
})
export const CommentMarkdownDocument = defineMarkdownDocumentComponent({
name: 'CommentMarkdownDocument',
extends: BaseMarkdownDocument,
components: { alert: CommentAlert },
})Live Documents
MarkdownDocument can subscribe to an ambient context so external sources can drive a mounted renderer: an HMR signal, a collaboration socket, an agent editing the document while you chat with it, or devtools. Pass a documentKey, and if globalThis.comarkContext exists the renderer listens for updates on that key and re-renders; on unmount it cleans up. The key falls back to the document's own meta.key when a plugin sets it. With no context, it's a no-op at zero cost.
<MarkdownDocument documentKey="page" value={document} />A driver installs the context once and pushes updates by key with set() (replace the whole document) or patch() (surgical node edits, with structural sharing so only the changed branch re-renders):
import { createComarkContext, parseMarkdown } from 'comark'
const ctx = createComarkContext() // installs globalThis.comarkContext
const doc = ctx.get('page', await parseMarkdown('# Hello')) // seed on first access
doc.set(await parseMarkdown('# Replaced'))
doc.patch({ op: 'insert', path: [1], node: ['p', {}, 'inserted'] })A path is a node-index path into document.nodes: the first segment indexes the top-level nodes, each later segment indexes into that element's children. Patch operations are replace, insert, remove (each takes a path), plus meta, frontmatter, and data merges. The same context API powers @comark/vue, @comark/svelte, and @comark/angular: websocket handlers, agents, devtools, and HMR all drive it the same way.
Component Bindings
Comark automatically bridges the gap between Comark syntax and your component's interface.
Prop Binding
Attributes in Comark syntax are passed as props to your component. Use the : prefix to pass typed values. HTML attribute names are automatically converted to their React equivalents:
| Markdown | React prop |
|---|---|
{type="warning"} | type="warning" (string) |
{:count="5"} | count={5} (number) |
{:active="true"} | active={true} (boolean) |
{:config='{"key":"val"}'} | config={{ key: 'val' }} (object) |
{class="foo"} | className="foo" |
{tabindex="0"} | tabIndex={0} |
{style="color: red"} | style={{ color: 'red' }} |
Named Slots
Named slots in Comark (#slotname) map to slot{Name} props in React:
- Default slot →
children - Named slots →
slot{Name}(e.g.,#footer→slotFooter)
interface CardProps {
title?: string
children?: React.ReactNode
slotFooter?: React.ReactNode
}
export default function Card({ title, children, slotFooter }: CardProps) {
return (
<div className="card">
{title && <h3>{title}</h3>}
<div className="card-body">{children}</div>
{slotFooter && <div className="card-footer">{slotFooter}</div>}
</div>
)
}::card{title="My Card"}
Default slot content.
#footer
Footer slot content.
::Overriding HTML Elements
Override how native HTML elements render by mapping a component to their tag name via the components prop.
Create overridden version
interface HeadingProps {
__node?: ElementNode
id?: string
children: React.ReactNode
}
export default function Heading({ __node, id, children }: HeadingProps) {
const Tag = __node?.[0] || 'h2'
return (
<Tag id={id} className="heading">
{id && <a href={`#${id}`} className="anchor">#</a>}
{children}
</Tag>
)
}Map
<Markdown components={{ h1: Heading, h2: Heading, h3: Heading }}>
{content}
</Markdown>Resolution Order
Components are resolved in this order:
Prose{PascalTag}: e.g.,ProseH1forh1{PascalTag}: e.g.,Alertforalert{tag}: e.g.,alert
If no custom component matches, the tag renders as a native HTML element.
Streaming
Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.
Setup
Set streaming to true while content is being received, then false when done:
import { useState } from 'react'
import { Markdown } from '@comark/react'
export default function AiChat() {
const [content, setContent] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
async function askAI(prompt: string) {
setContent('')
setIsStreaming(true)
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
setContent(accumulated)
}
setIsStreaming(false)
}
return (
<Markdown streaming={isStreaming} caret>
{content}
</Markdown>
)
}autoClose is enabled by default: incomplete syntax like **bold text is automatically closed on every parse. Disable with options={{ autoClose: false }}.Caret
The caret prop appends a blinking cursor to the last text node while streaming is true:
{/* Default caret */}
<Markdown streaming={isStreaming} caret>{content}</Markdown>
{/* Custom caret class */}
<Markdown streaming={isStreaming} caret={{ class: 'my-caret' }}>{content}</Markdown>.my-caret {
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
50% { opacity: 0; }
}TypeScript Support
Use ComarkPlugin from comark to type plugin arrays, and ElementNode to type the __node prop in components that override HTML elements:
import type { ComponentType } from 'react'
import type { ComarkPlugin } from 'comark'
import { Markdown } from '@comark/react'
interface Props {
content: string
components?: Record<string, ComponentType<any>>
plugins?: ComarkPlugin[]
}
export default function ComarkWrapper({ content, components, plugins }: Props) {
return <Markdown components={components} plugins={plugins}>{content}</Markdown>
}import type { ElementNode } from 'comark'
interface HeadingProps {
__node?: ElementNode
id?: string
children: React.ReactNode
}
export default function Heading({ __node, id, children }: HeadingProps) {
const Tag = __node?.[0] || 'h2'
return <Tag id={id}>{children}</Tag>
}