Render Comark in Svelte
The @comark/svelte package provides Svelte 5 components for rendering Comark content with full support for custom components, plugins, and streaming.
Installation
pnpm add @comark/sveltenpm install @comark/svelteyarn add @comark/sveltebun add @comark/svelte<Markdown>
The <Markdown> component is the simplest way to render markdown in Svelte 5. It handles parsing and rendering automatically using $state and $effect.
<Markdown> uses $effect internally and will not render during SSR. For SvelteKit, parse in your load() function and use <MarkdownDocument> instead.Usage
Pass markdown content via the value prop. value accepts a markdown string or a pre-parsed MarkdownDocument:
<script lang="ts">
import { Markdown } from '@comark/svelte'
let content = $state('# Hello\n\nThis is **markdown**.')
</script>
<Markdown value={content} /><script lang="ts">
import type { MarkdownDocument } from 'comark'
import { Markdown } from '@comark/svelte'
let { document }: { document: MarkdownDocument } = $props()
</script>
<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 |
|---|---|---|---|
value | string | MarkdownDocument | '' | Markdown string or pre-parsed document |
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, Component> | {} | Custom Svelte component mappings |
componentsManifest | ComponentManifest | 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" |
class | string | '' | CSS class for wrapper element |
options
<Markdown value={content} options={{ autoUnwrap: true, autoClose: true }} />plugins
See ComarkPlugin for available plugins.
<script lang="ts">
import { Markdown } from '@comark/svelte'
import shiki from '@comark/svelte/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
const plugins = [
shiki({ themes: { light: githubLight } }),
]
</script>
<Markdown value="```js\nconsole.log('hello')\n```" {plugins} />For math and mermaid plugins, also pass the companion components:
<script lang="ts">
import { Markdown } from '@comark/svelte'
import math, { Math } from '@comark/svelte/plugins/math'
import mermaid, { Mermaid } from '@comark/svelte/plugins/mermaid'
import 'katex/dist/katex.min.css'
</script>
<Markdown
value={markdown}
components={{ math: Math, mermaid: Mermaid }}
plugins={[math(), mermaid()]}
/>components
Map custom Svelte components to Comark elements. Components receive props from the markdown and children as a Svelte children snippet.
Create a Svelte component
<script lang="ts">
import type { Snippet } from 'svelte'
let { type = 'info', children }: { type?: string, children?: Snippet } = $props()
</script>
<div class="alert alert-{type}" role="alert">
{@render children?.()}
</div>Map the tag to your component
<script lang="ts">
import { Markdown } from '@comark/svelte'
import Alert from './components/comark/Alert.svelte'
const components = { alert: Alert }
</script>
<Markdown value={content} {components} />Use it in your Markdown content
::alert{type="warning"}
This is a warning message!
::componentsManifest
A function that resolves component names to Svelte components at runtime. It can return a module (or Promise<module>) for lazy loading, or the component directly for synchronous resolution.
src/components/comark/ or, in SvelteKit, $lib/components/comark/. This keeps Comark-rendered components separate from normal app UI components and makes componentsManifest globs easier to audit.Lazy loading: components are imported on demand when first rendered. This works with <Markdown> on the client:
<script lang="ts">
import { Markdown } from '@comark/svelte'
const manifest = (name: string) => {
return import(`./components/comark/${name}.svelte`)
}
</script>
<Markdown value={markdown} componentsManifest={manifest} />Lazy loading with SvelteKit SSR: use <MarkdownAsync> and return dynamic imports from componentsManifest. SvelteKit awaits async SSR work, so only rendered components are loaded and included in the server HTML:
<script lang="ts">
import { MarkdownAsync } from '@comark/svelte/async'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
const componentMap: Record<string, () => Promise<any>> = {
'alert': () => import('$lib/components/comark/Alert.svelte'),
'lazy-card': () => import('$lib/components/comark/LazyCard.svelte'),
}
const componentsManifest = (name: string) => componentMap[name]?.()
</script>
<svelte:boundary>
<MarkdownAsync value={data.markdown} {componentsManifest} />
</svelte:boundary>You can also use import.meta.glob when you want the manifest to cover every Svelte component in a folder:
<script lang="ts">
import { MarkdownAsync } from '@comark/svelte/async'
import { pascalCase } from 'comark/utils'
const modules = import.meta.glob('../lib/components/comark/*.svelte')
const componentsManifest = (name: string) => {
return modules[`../lib/components/comark/${pascalCase(name)}.svelte`]?.()
}
</script>
<svelte:boundary>
<MarkdownAsync value={data.markdown} {componentsManifest} />
</svelte:boundary>pending snippet to <svelte:boundary>, SSR renders that fallback instead of waiting for the lazy component HTML. Omit pending when you want the resolved components in the initial server HTML.Stable SSR without experimental async: use import.meta.glob with eager: true so <MarkdownDocument> can render components synchronously:
<script lang="ts">
import { MarkdownDocument } from '@comark/svelte'
import { pascalCase } from '@comark/svelte/utils'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
const modules = import.meta.glob('../lib/components/comark/*.svelte', {
eager: true,
})
const componentsManifest = (name: string) => {
return modules[`../lib/components/comark/${pascalCase(name)}.svelte`]
}
</script>
<MarkdownDocument value={data.document} {componentsManifest} />componentMap is easiest to audit and lets you choose public Markdown tags one by one. import.meta.glob is useful when you want a whole folder to become available by convention; Vite resolves matching files at build time. With eager: true, modules are imported statically. Without it, Vite generates dynamic import() calls that load each module lazily at runtime.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.
<script lang="ts">
import { Markdown } from '@comark/svelte'
const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
</script>
<Markdown value={content} data={{ user }} /><MarkdownAsync> (experimental)
<MarkdownAsync> uses Svelte's experimental await in $derived for a more declarative approach. It also awaits async componentsManifest entries during SSR, so lazy component imports render into SvelteKit server HTML. Requires experimental.async in your Svelte config:
const config = {
compilerOptions: {
experimental: {
async: true,
},
},
}
export default config<script lang="ts">
import { MarkdownAsync } from '@comark/svelte/async'
let content = $state('# Hello World')
</script>
<svelte:boundary>
<MarkdownAsync value={content} />
{#snippet pending()}
<p>Loading...</p>
{/snippet}
{#snippet failed(error, reset)}
<p>Error: {error.message}</p>
<button onclick={reset}>Retry</button>
{/snippet}
</svelte:boundary>experimental.async feature is still experimental in Svelte 5. For production SSR without experimental async, prefer <MarkdownDocument> with eager/static components.<MarkdownAsync> when you need SSR HTML for non-eager, lazy-loaded Svelte components. Use <MarkdownDocument> with eager/static components when you want stable, non-experimental SSR.<MarkdownDocument>
Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server (e.g., in a SvelteKit load function), so no parser or plugin code is shipped to the browser.
Integration
Fetch the document in your load function
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
const res = await fetch(`/api/content/${params.slug}`)
const document = await res.json()
return { document }
}Render with MarkdownDocument
<script lang="ts">
import { MarkdownDocument } from '@comark/svelte'
import Alert from '$lib/components/comark/Alert.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
</script>
<MarkdownDocument value={data.document} components={{ alert: Alert }} />Renderer Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | MarkdownDocument | — | Required. The parsed document returned by parseMarkdown() |
components | Record<string, Component> | {} | Custom Svelte 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" |
class | string | '' | CSS class for wrapper element |
componentsManifest
For lazy-loading components on demand:
<script lang="ts">
import { MarkdownDocument } from '@comark/svelte'
import { pascalCase } from '@comark/svelte/utils'
const modules = import.meta.glob('./components/comark/*.svelte')
const manifest = (name: string) => {
return modules[`./components/comark/${pascalCase(name)}.svelte`]?.()
}
let { data } = $props()
</script>
<MarkdownDocument value={data.document} componentsManifest={manifest} /><MarkdownDocument> can only render manifest entries synchronously. Use eager/static components for SSR HTML, or use <MarkdownAsync> when the manifest returns dynamic imports.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/react, 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:
| Markdown | Prop value |
|---|---|
{type="warning"} | "warning" (string) |
{:count="5"} | 5 (number) |
{:active="true"} | true (boolean) |
{:config='{"key":"val"}'} | { key: 'val' } (object) |
Named Snippets
Named slots in Comark (#slotname) map to Svelte 5 snippets:
- Default content →
childrensnippet - Named snippets → named snippet prop (e.g.,
#footer→footersnippet)
<script lang="ts">
import type { Snippet } from 'svelte'
let { title, children, footer }: {
title?: string
children?: Snippet
footer?: Snippet
} = $props()
</script>
<div class="card">
<h3>{title}</h3>
{@render children?.()}
<footer>
{@render footer?.()}
</footer>
</div>::card{title="My Card"}
Default slot content.
#footer
Footer slot content.
::Overriding HTML Elements
Use the Prose prefix to override how native HTML elements render:
Create an override component
<script lang="ts">
import type { Snippet } from 'svelte'
let { id, children }: { id?: string, children?: Snippet } = $props()
</script>
<h1 {id} class="custom-heading">
{@render children?.()}
</h1>Map it via the components prop
<Markdown value={content} components={{ ProseH1 }} />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 (via <svelte:element>).
Streaming
Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.
Set streaming to true while content is being received, then false when done:
<script lang="ts">
import { Markdown } from '@comark/svelte'
let content = $state('')
let isStreaming = $state(false)
async function askAI(prompt: string) {
content = ''
isStreaming = true
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
}
isStreaming = false
}
</script>
<Markdown value={content} streaming={isStreaming} caret />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 value={content} streaming={isStreaming} caret />
<!-- Custom caret class -->
<Markdown value={content} streaming={isStreaming} caret={{ class: 'my-caret' }} />.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
<script lang="ts">
import { Markdown } from '@comark/svelte'
import type { ComarkPlugin } from 'comark'
import type { Component } from 'svelte'
let {
content,
components,
plugins,
}: {
content: string
components?: Record<string, Component>
plugins?: ComarkPlugin[]
} = $props()
</script>
<Markdown value={content} {components} {plugins} /><script lang="ts">
import type { Snippet } from 'svelte'
import type { ElementNode } from 'comark'
let { id, __node, children }: {
id?: string
__node?: ElementNode
children?: Snippet
} = $props()
</script>