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<Comark>
The <Comark> component is the simplest way to render markdown in Svelte 5. It handles parsing and rendering automatically using $state and $effect.
<Comark> uses $effect internally and will not render during SSR. For SvelteKit, parse in your load() function and use <ComarkRenderer> instead.Usage
Pass markdown content via the markdown prop:
<script lang="ts">
import { Comark } from '@comark/svelte'
let content = $state('# Hello\n\nThis is **markdown**.')
</script>
<Comark markdown={content} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
markdown | string | '' | Markdown content to parse and render |
options | ParseOptions | {} | Parser options (autoUnwrap, autoClose, etc.) |
plugins | ComarkPlugin[] | [] | Array of plugins |
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
<Comark markdown={content} options={{ autoUnwrap: true, autoClose: true }} />plugins
See ComarkPlugin for available plugins.
<script lang="ts">
import { Comark } from '@comark/svelte'
import highlight from '@comark/svelte/plugins/highlight'
import githubLight from '@shikijs/themes/github-light'
const plugins = [
highlight({ theme: githubLight }),
]
</script>
<Comark markdown="```js\nconsole.log('hello')\n```" {plugins} />For math and mermaid plugins, also pass the companion components:
<script lang="ts">
import { Comark } 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>
<Comark
{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 { Comark } from '@comark/svelte'
import Alert from './components/comark/Alert.svelte'
const components = { alert: Alert }
</script>
<Comark markdown={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 <Comark> on the client:
<script lang="ts">
import { Comark } from '@comark/svelte'
const manifest = (name: string) => {
return import(`./components/comark/${name}.svelte`)
}
</script>
<Comark {markdown} componentsManifest={manifest} />Lazy loading with SvelteKit SSR — use <ComarkAsync> 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 { ComarkAsync } 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>
<ComarkAsync markdown={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 { ComarkAsync } from '@comark/svelte/async'
import { pascalCase } from '@comark/svelte/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>
<ComarkAsync markdown={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 <ComarkRenderer> can render components synchronously:
<script lang="ts">
import { ComarkRenderer } 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>
<ComarkRenderer tree={data.tree} {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 { Comark } from '@comark/svelte'
const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
</script>
<Comark markdown={content} data={{ user }} /><ComarkAsync> (experimental)
<ComarkAsync> 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 { ComarkAsync } from '@comark/svelte/async'
let content = $state('# Hello World')
</script>
<svelte:boundary>
<ComarkAsync markdown={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 <ComarkRenderer> with eager/static components.<ComarkAsync> when you need SSR HTML for non-eager, lazy-loaded Svelte components. Use <ComarkRenderer> with eager/static components when you want stable, non-experimental SSR.<ComarkRenderer>
Renders a pre-parsed ComarkTree without any parsing. Use it when you parse on the server (e.g., in a SvelteKit load function) — no parser or plugin code is shipped to the browser.
Integration
Fetch the tree 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 tree = await res.json()
return { tree }
}Render with ComarkRenderer
<script lang="ts">
import { ComarkRenderer } from '@comark/svelte'
import Alert from '$lib/components/comark/Alert.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
</script>
<ComarkRenderer tree={data.tree} components={{ alert: Alert }} />Renderer Props
| Prop | Type | Default | Description |
|---|---|---|---|
tree | ComarkTree | — | Required. The parsed tree returned by parse() |
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 { ComarkRenderer } 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>
<ComarkRenderer tree={data.tree} componentsManifest={manifest} /><ComarkRenderer> can only render manifest entries synchronously. Use eager/static components for SSR HTML, or use <ComarkAsync> when the manifest returns dynamic imports.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
<Comark markdown={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 { Comark } 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>
<Comark markdown={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 -->
<Comark markdown={content} streaming={isStreaming} caret />
<!-- Custom caret class -->
<Comark markdown={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 { Comark } 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>
<Comark markdown={content} {components} {plugins} /><script lang="ts">
import type { Snippet } from 'svelte'
import type { ComarkElement } from 'comark'
let { id, __node, children }: {
id?: string
__node?: ComarkElement
children?: Snippet
} = $props()
</script>