---
title: "Render Comark in Svelte"
description: "Learn how to render Comark in a Svelte 5 application with custom components, plugins, and streaming support."
canonical_url: "https://comark.dev/rendering/svelte"
---
# 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

<code-group>
```bash [pnpm]
pnpm add @comark/svelte
```


```bash [npm]
npm install @comark/svelte
```


```bash [yarn]
yarn add @comark/svelte
```


```bash [bun]
bun add @comark/svelte
```
</code-group>

## `<Markdown>`

The `<Markdown>` component is the quickest way to render markdown in Svelte 5. It handles parsing and rendering automatically using `$state` and `$effect`.

<warning>
`<Markdown>` uses `$effect` internally and **will not render during SSR**. For SvelteKit, parse in your `load()` function and use [`<MarkdownDocument>`](#code-markdowndocument) instead.
</warning>

### Usage

Pass markdown content via the `value` prop. `value` accepts a markdown **string** or a pre-parsed [`MarkdownDocument`](https://comark.dev/getting-started/document-model):

<code-group>
```svelte [String]
<script lang="ts">
  import { Markdown } from '@comark/svelte'

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

<Markdown value={content} />
```


```svelte [MarkdownDocument]
<script lang="ts">
  import type { MarkdownDocument } from 'comark'
  import { Markdown } from '@comark/svelte'

  let { document }: { document: MarkdownDocument } = $props()
</script>

<Markdown value={document} />
```
</code-group>

<callout icon="i-lucide-package" color="warning">
Passing a document to `<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>`](#code-markdowndocument) instead.
</callout>

### Props

| Prop                                                                 | Type                            | Default     | Description                                                                                                                                      |
| -------------------------------------------------------------------- | ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `value`                                                              | `string \| MarkdownDocument`    | `''`        | Markdown string or pre-parsed document                                                                                                           |
| [`options`](#code-markdown-props-code-options)                       | `ParserOptions`                 | `{}`        | Parser options (autoUnwrap, autoClose, etc.)                                                                                                     |
| [`plugins`](#code-markdown-props-code-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, for example `"ul li"` |
| [`components`](#code-markdown-props-code-components)                 | `Record<string, Component>`     | `{}`        | Custom Svelte component mappings                                                                                                                 |
| [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest`             | `undefined` | Dynamic component resolver                                                                                                                       |
| [`streaming`](#streaming)                                            | `boolean`                       | `false`     | Enable streaming mode                                                                                                                            |
| [`caret`](#streaming-caret)                                          | `boolean \| { class: string }`  | `false`     | Append caret to last text node                                                                                                                   |
| [`data`](#code-markdown-props-code-data)                             | `Record<string, unknown>`       | `undefined` | Runtime values referenced from markdown via `:prop="data.path"`                                                                                  |
| `class`                                                              | `string`                        | `''`        | CSS class for wrapper element                                                                                                                    |

#### `options`

```svelte [App.svelte]
<Markdown value={content} options={{ autoUnwrap: true, autoClose: true }} />
```

#### `plugins`

See [ComarkPlugin](https://comark.dev/plugins) for available plugins.

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

```svelte [App.svelte]
<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.

<steps level="4">
#### Create a Svelte component


```svelte [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


```svelte [App.svelte]
<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


```mdc
::alert{type="warning"}
This is a warning message!
::
```
</steps>

<tip>
See [Component Bindings](#component-bindings) for how props and snippets map to your Svelte component.
</tip>

#### `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.

<tip>
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.
</tip>

**Lazy loading**: components are imported on demand when first rendered. This works with `<Markdown>` on the client:

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

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

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

<warning>
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.
</warning>

**Stable SSR without experimental async**: use `import.meta.glob` with `eager: true` so `<MarkdownDocument>` can render components synchronously:

```svelte [routes/+page.svelte]
<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} />
```

<tip>
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.
</tip>

#### `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](https://comark.dev/syntax/components#data-binding) for the full scope.

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

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

export default config
```

```svelte [App.svelte]
<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>
```

<callout icon="i-lucide-triangle-alert" color="warning">
The `experimental.async` feature is still experimental in Svelte 5. For production SSR without experimental async, prefer `<MarkdownDocument>` with eager/static components.
</callout>

Use `<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. See the [SvelteKit example](https://comark.dev/examples/frameworks/sveltekit) for a complete local app with lazy SSR and stable SSR routes.

---

## `<MarkdownDocument>`

Renders a pre-parsed `MarkdownDocument` without any parsing. Use it when you parse on the server (for example, in a SvelteKit `load` function), so no parser or plugin code is shipped to the browser.

### Integration

<steps level="4">
#### Fetch the document in your load function


```typescript [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 document = await res.json()
  return { document }
}
```


#### Render with `MarkdownDocument`


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

### Renderer props

| Prop                                                                   | Type                           | Default     | Description                                                     |
| ---------------------------------------------------------------------- | ------------------------------ | ----------- | --------------------------------------------------------------- |
| `value`                                                                | `MarkdownDocument`             | —           | **Required.** The parsed document returned by `parseMarkdown()` |
| [`components`](#code-markdown-props-code-components)                   | `Record<string, Component>`    | `{}`        | Custom Svelte component mappings                                |
| [`componentsManifest`](#code-markdowndocument-code-componentsmanifest) | `ComponentManifest`            | `undefined` | Dynamic component resolver for lazy-loaded components           |
| [`streaming`](#streaming)                                              | `boolean`                      | `false`     | Enable streaming mode                                           |
| [`caret`](#streaming-caret)                                            | `boolean \| { class: string }` | `false`     | Append a blinking caret to the last text node                   |
| [`data`](#code-markdown-props-code-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:

```svelte [App.svelte]
<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} />
```

<warning>
During SSR, `<MarkdownDocument>` can only render manifest entries synchronously. Use eager/static components for SSR HTML, or use [`<MarkdownAsync>`](#code-markdownasync-code-experimental) when the manifest returns dynamic imports.
</warning>

---

## 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.

```svelte
<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):

```ts
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** → `children` snippet
- **Named snippets** → named snippet prop (for example, `#footer` → `footer` snippet)

<code-group>
```svelte [Svelte]
<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>
```


```mdc [Comark]
::card{title="My Card"}
Default slot content.

#footer
Footer slot content.
::
```
</code-group>

---

## Overriding HTML elements

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

<steps level="3">
### Create an override component


```svelte [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


```svelte [App.svelte]
<Markdown value={content} components={{ ProseH1 }} />
```
</steps>

### Resolution order

Components are resolved in this order:

1. `Prose{PascalTag}`: for example, `ProseH1` for `h1`
2. `{PascalTag}`: for example, `Alert` for `alert`
3. `{tag}`: for example, `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:

```svelte [components/AiChat.svelte]
<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 />
```

<callout icon="i-lucide-info" color="info">
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `options={{ autoClose: false }}`.
</callout>

### Caret

The `caret` prop appends a blinking cursor to the last text node while `streaming` is `true`:

```svelte [App.svelte]
<!-- Default caret -->
<Markdown value={content} streaming={isStreaming} caret />

<!-- Custom caret class -->
<Markdown value={content} streaming={isStreaming} caret={{ class: 'my-caret' }} />
```

```css
.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

```svelte [ComarkWrapper.svelte]
<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} />
```

```svelte [src/components/comark/ProseH1.svelte]
<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>
```

---

---

- [Plugins](https://comark.dev/plugins)
- [Streaming API](https://comark.dev/reference/auto-close)


## Sitemap

See the full [sitemap](https://comark.dev/sitemap.md) for all pages.
