Comark vs svelte-markdown

Compare Comark with svelte-markdown and its Svelte 5 successor @humanspeak/svelte-markdown. See how each handles custom renderers, components in content, LLM streaming, and SvelteKit SSR.

TL;DR: Two packages share this name. svelte-markdown renders marked tokens to Svelte components, but its last release was in December 2023 and it declares Svelte 4. @humanspeak/svelte-markdown is its Svelte 5 successor, with strong streaming support and marked extensions. Comark's @comark/svelte adds a component syntax for the content, completes unterminated syntax while a response streams, and renders the same content in other frameworks.

At a glance

Comark@humanspeak/svelte-markdownsvelte-markdown
Svelte versionSvelte 5Svelte 5 (runes, snippets)Peer svelte ^4.0.0
Parsermarkdown-exitmarked and htmlparser2marked 5
Restyle elementscomponents map, Prose prefixrenderers and snippet overridesrenderers
Components in the content::alert{type="info"} syntaxAny HTML tag via renderers.htmlNo
Named slots#footer maps to a named snippetHTML tags get children onlyNo
Streamingstreaming prop, autoClose completes syntaxstreaming prop, writeChunk(), IncrementalParserNo
ExtensionsComark plugins, markdown-it pluginsmarked extensions, first-party KaTeX, Mermaid, alerts, footnotesmarked options
Other outputsVue, React, Angular, HTML, ANSISvelte onlySvelte only

Which svelte-markdown?

The original svelte-markdown by Pablo Berganza is built on marked 5. Its renderers prop maps marked token types, such as heading or link, to Svelte components. Svelte 5 support isn't declared, and issue #99 about it is open since October 2024. Its README also states that a paragraph must be all HTML or all Markdown.

@humanspeak/svelte-markdown describes itself as the successor to the original package. It keeps the source and renderers props and adds runes, snippet overrides, streaming, and an HTML parser. If you start a new Svelte 5 project with this API, use the successor.

Components in the content

Both maintained options let the content place your components, with different syntax.

@humanspeak/svelte-markdown routes HTML tags to components. Any tag name works, so a model can write <thinking> or <click>:

@humanspeak/svelte-markdown
<script lang="ts">
  import SvelteMarkdown from '@humanspeak/svelte-markdown'
  import ClickButton from './ClickButton.svelte'

  const renderers = { html: { click: ClickButton } }
</script>

<SvelteMarkdown source={'<click>Click Me</click>'} {renderers} />

Comark uses a component syntax with typed props and named slots. Each #slot becomes a Svelte 5 snippet prop:

content.md
::card{title="Order summary" :items="3"}
Your order ships tomorrow.

#footer
Questions? Reply to this message.
::
Card.svelte
<script lang="ts">
  import type { Snippet } from 'svelte'

  let { title, items, children, footer }: {
    title?: string
    items?: number
    children?: Snippet
    footer?: Snippet
  } = $props()
</script>

<div class="card">
  <h3>{title} ({items} items)</h3>
  {@render children?.()}
  <footer>{@render footer?.()}</footer>
</div>

Register it with <Markdown value={content} components={{ card: Card }} />. For lazy loading, pass a componentsManifest function instead. See component bindings.

Streaming

@humanspeak/svelte-markdown has a detailed streaming engine. You bind the component and call writeChunk(), or append to source. It re-lexes only the open block at the end of the text and coalesces updates once per animation frame. Offset chunks can arrive out of order, and streamId resets the buffer between messages. Its documentation doesn't describe completing unterminated inline syntax; half-open tags wait in a buffer. Streaming turns off when you use an async extension such as Mermaid.

Comark completes the syntax with autoClose. **bold renders as bold, and an open ::card renders as a card while its content streams. You pass the growing text to value:

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

  let { text, isStreaming }: { text: string, isStreaming: boolean } = $props()
</script>

<Markdown value={text} streaming={isStreaming} caret />

While streaming is true, <Markdown> reuses completed blocks. Set it to false when the stream ends to run a final full parse. With the AI SDK, use part.state === 'streaming' for the prop. See streaming in Svelte.

SvelteKit SSR

Comark's <Markdown> uses $effect, so it doesn't render during SSR. For SvelteKit pages, parse in load() and render the JSON-safe document with <MarkdownDocument>:

src/routes/docs/[slug]/+page.svelte
<script lang="ts">
  import { MarkdownDocument } from '@comark/svelte'
  import Alert from '$lib/components/comark/Alert.svelte'

  let { data } = $props()
</script>

<MarkdownDocument value={data.document} components={{ alert: Alert }} />

<MarkdownDocument> ships no parser to the browser. To render lazy-loaded components in server HTML, use <MarkdownAsync> from @comark/svelte/async. It requires Svelte's experimental.async option.

What @humanspeak/svelte-markdown does well

  • Svelte 5-native API: snippet overrides let you change a renderer inline, without a separate component file.
  • Streaming engineering: open-block re-lexing, per-frame coalescing, and out-of-order offset chunks are well documented.
  • Secure defaults: a URL allowlist, on* and srcdoc stripping, and escaped script and style work without setup. Its documentation lists gaps, for example iframe, form, and embed still render. Comark keeps raw <script> until you add the security plugin with a blockedTags list.
  • marked ecosystem: existing marked extensions plug in through the extensions prop.

If your app is Svelte-only and you write standard Markdown with HTML tags, @humanspeak/svelte-markdown is a good fit. Choose Comark when the content needs components with props and slots. Comark also fits when the same content renders in Vue, React, Angular, HTML, or a terminal.

FAQ

© 2026 Vercel, Inc.