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<Comark>
The <Comark> component is the simplest way to render markdown in React. It handles parsing and rendering automatically.
<Comark> is an async component. You can also use the <ComarkRenderer> component to handle parsing yourself.import { Comark } from '@comark/react'
const content = `# Hello World
This is **markdown** with Comark components.
`
export default function App() {
return <Comark>{content}</Comark>
}Usage
Pass markdown content via children or the markdown prop:
<Comark>{content}</Comark><Comark markdown={content} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Markdown content to parse and render |
markdown | string | '' | Alternative to children |
options | ParseOptions | {} | 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 ParseOptions for available options.
<Comark options={{ autoUnwrap: true, autoClose: true }}>
{content}
</Comark>plugins
See ComarkPlugin for available plugins.
import { Comark } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
const plugins = [
highlight({
themes: { light: 'github-light', dark: 'github-dark' }
})
]
export default function App() {
return <Comark plugins={plugins}>{content}</Comark>
}For math and mermaid plugins, also pass the companion components:
import { Comark } 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 (
<Comark
markdown={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 { Comark } from '@comark/react'
import Alert from './components/Alert'
import Card from './components/Card'
const components = { alert: Alert, card: Card }
export default function App() {
return <Comark components={components}>{content}</Comark>
}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 <Comark> and <ComarkRenderer>:
import { Comark } from '@comark/react'
const manifest = (name: string) => {
return import(`./components/prose/${name}.tsx`)
}
export default function App() {
return <Comark componentsManifest={manifest}>{content}</Comark>
}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 { Comark } from '@comark/react'
const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
export default function App() {
return <Comark markdown={content} data={{ user }} />
}defineComarkComponent
Creates a pre-configured <Comark> component with default options, plugins, and components baked in.
Usage
Expose your configured component
import { defineComarkComponent } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import CustomAlert from './components/CustomAlert'
export const AppComark = defineComarkComponent({
name: 'AppComark',
plugins: [
math(),
mermaid(),
highlight({
themes: {
light: 'github-light',
dark: 'github-dark'
},
}),
],
components: {
Math,
Mermaid,
alert: CustomAlert,
},
})Use it in your templates
import { AppComark } from './comark'
export default function App() {
return (
<>
{/* All configuration is already included */}
<AppComark>{content}</AppComark>
{/* Can still override per-instance */}
<AppComark components={{ alert: DifferentAlert }}>
{content}
</AppComark>
</>
)
}Options
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineComarkComponent> | 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 |
html | boolean | true | Parse embedded HTML tags into AST nodes |
linkify | boolean | true | Auto-convert URL-like text into links |
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 { defineComarkComponent } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
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 BaseComark = defineComarkComponent({
name: 'BaseComark',
plugins: [highlight({ themes: { light: 'github-light', dark: 'github-dark' } })],
components: { pre: CodeBlock },
})
// Article: extends Base, adds TOC and math
export const ArticleComark = defineComarkComponent({
name: 'ArticleComark',
extends: BaseComark,
plugins: [toc({ depth: 3 }), math()],
components: { Math },
})
// Comment: extends Base only, no TOC, no math
export const CommentComark = defineComarkComponent({
name: 'CommentComark',
extends: BaseComark,
})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 { defineComarkComponent } from '@comark/react'
import math from '@comark/react/plugins/math'
import { Math } from '@comark/react/plugins/math'
export const DocsComark = defineComarkComponent({
name: 'DocsComark',
plugins: [math()],
components: { Math },
})import { DocsComark } from '@/components/comark'
export default async function Page({ params }: { params: { slug: string } }) {
const content = await getDocContent(params.slug)
return <DocsComark>{content}</DocsComark>
}<ComarkRenderer>
Renders a pre-parsed ComarkTree 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 tree directly in a React Server Component:
import { createParse } from 'comark'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { ComarkRenderer } from '@comark/react'
import Alert from '@/components/Alert'
const parse = createParse()
export default async function DocsPage({ params }: { params: { slug: string } }) {
const markdown = await readFile(join('content', `${params.slug}.md`), 'utf-8')
const tree = await parse(markdown)
return <ComarkRenderer tree={tree} components={{ alert: Alert }} />
}Renderer Props
| Prop | Type | Default | Description |
|---|---|---|---|
tree | ComarkTree | — | Required. The parsed tree returned by parse() |
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> |
defineComarkRendererComponent
Creates a pre-configured <ComarkRenderer> with baked-in component mappings.
Setup
Expose your configured renderer
import { defineComarkRendererComponent } from '@comark/react'
import Alert from './components/Alert'
import CodeBlock from './components/CodeBlock'
export const ArticleRenderer = defineComarkRendererComponent({
name: 'ArticleRenderer',
components: {
alert: Alert,
pre: CodeBlock,
},
})Page integration
import { parse } from 'comark'
import { ArticleRenderer } from '@/comark'
export default async function Page({ params }: { params: { slug: string } }) {
const markdown = await getContent(params.slug)
const tree = await parse(markdown)
return <ArticleRenderer tree={tree} />
}Renderer Options
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineComarkRendererComponent> | 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 { defineComarkRendererComponent } from '@comark/react'
import CodeBlock from './components/CodeBlock'
import ProseA from './components/ProseA'
import Alert from './components/Alert'
import CommentAlert from './components/CommentAlert'
const BaseRenderer = defineComarkRendererComponent({
name: 'BaseRenderer',
components: { pre: CodeBlock, a: ProseA },
})
export const ArticleRenderer = defineComarkRendererComponent({
name: 'ArticleRenderer',
extends: BaseRenderer,
components: { alert: Alert },
})
export const CommentRenderer = defineComarkRendererComponent({
name: 'CommentRenderer',
extends: BaseRenderer,
components: { alert: CommentAlert },
})Live Documents
ComarkRenderer 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 comarkKey, 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 tree's own meta.key when a plugin sets it. With no context, it's a no-op at zero cost.
<ComarkRenderer comarkKey="page" tree={tree} />A driver installs the context once and pushes updates by key with set() (replace the whole tree) or patch() (surgical node edits, with structural sharing so only the changed branch re-renders):
import { createComarkContext, parse } from 'comark'
const ctx = createComarkContext() // installs globalThis.comarkContext
const doc = ctx.get('page', await parse('# Hello')) // seed on first access
doc.set(await parse('# Replaced'))
doc.patch({ op: 'insert', path: [1], node: ['p', {}, 'inserted'] })A path is a node-index path into tree.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?: ComarkElement
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
<Comark components={{ h1: Heading, h2: Heading, h3: Heading }}>
{content}
</Comark>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 { Comark } 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 (
<Comark streaming={isStreaming} caret>
{content}
</Comark>
)
}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 */}
<Comark streaming={isStreaming} caret>{content}</Comark>
{/* Custom caret class */}
<Comark streaming={isStreaming} caret={{ class: 'my-caret' }}>{content}</Comark>.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 ComarkElement to type the __node prop in components that override HTML elements:
import type { ComponentType } from 'react'
import type { ComarkPlugin } from 'comark'
import { Comark } from '@comark/react'
interface Props {
content: string
components?: Record<string, ComponentType<any>>
plugins?: ComarkPlugin[]
}
export default function ComarkWrapper({ content, components, plugins }: Props) {
return <Comark components={components} plugins={plugins}>{content}</Comark>
}import type { ComarkElement } from 'comark'
interface HeadingProps {
__node?: ComarkElement
id?: string
children: React.ReactNode
}
export default function Heading({ __node, id, children }: HeadingProps) {
const Tag = __node?.[0] || 'h2'
return <Tag id={id}>{children}</Tag>
}