Rendering

Render Comark in Svelte

Learn how to render Comark in a Svelte 5 application with custom components, plugins, and streaming support.

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/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:

App.svelte
<script lang="ts">
  import { Comark } from '@comark/svelte'

  let content = $state('# Hello\n\nThis is **markdown**.')
</script>

<Comark markdown={content} />

Props

PropTypeDefaultDescription
markdownstring''Markdown content to parse and render
optionsParseOptions{}Parser options (autoUnwrap, autoClose, etc.)
pluginsComarkPlugin[][]Array of plugins
componentsRecord<string, Component>{}Custom Svelte component mappings
componentsManifestComponentManifestundefinedDynamic component resolver
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend caret to last text node
dataRecord<string, unknown>undefinedRuntime values referenced from markdown via :prop="data.path"
classstring''CSS class for wrapper element

options

App.svelte
<Comark markdown={content} options={{ autoUnwrap: true, autoClose: true }} />

plugins

See ComarkPlugin for available plugins.

App.svelte
<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:

App.svelte
<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

src/components/comark/Alert.svelte
<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

App.svelte
<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!
::
See Component Bindings for how props and snippets map to your Svelte component.

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.

Keep components that are rendered from Markdown in a dedicated folder such as 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:

App.svelte
<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:

routes/+page.svelte
<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:

routes/+page.svelte
<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>
If you add a 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:

routes/+page.svelte
<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} />
An explicit 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.

App.svelte
<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:

svelte.config.js
const config = {
  compilerOptions: {
    experimental: {
      async: true,
    },
  },
}

export default config
App.svelte
<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>
The experimental.async feature is still experimental in Svelte 5. For production SSR without experimental async, prefer <ComarkRenderer> with eager/static components.
Use <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.
See the SvelteKit example for a complete local app with lazy SSR and stable SSR routes.

<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

src/routes/docs/[slug]/+page.ts
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

src/routes/docs/[slug]/+page.svelte
<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

PropTypeDefaultDescription
treeComarkTreeRequired. The parsed tree returned by parse()
componentsRecord<string, Component>{}Custom Svelte component mappings
componentsManifestComponentManifestundefinedDynamic component resolver for lazy-loaded components
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend a blinking caret to the last text node
dataRecord<string, unknown>undefinedRuntime values referenced from markdown via :prop="data.path"
classstring''CSS class for wrapper element

componentsManifest

For lazy-loading components on demand:

App.svelte
<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} />
During SSR, <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:

MarkdownProp 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 contentchildren snippet
  • Named snippets → named snippet prop (e.g., #footerfooter snippet)
<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>

Overriding HTML Elements

Use the Prose prefix to override how native HTML elements render:

Create an override component

ProseH1.svelte
<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

App.svelte
<Comark markdown={content} components={{ ProseH1 }} />

Resolution Order

Components are resolved in this order:

  1. Prose{PascalTag} — e.g., ProseH1 for h1
  2. {PascalTag} — e.g., Alert for alert
  3. {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:

components/AiChat.svelte
<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:

App.svelte
<!-- 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

ComarkWrapper.svelte
<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} />
src/components/comark/ProseH1.svelte
<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>