Angular Markdown Component with Streaming (v17+)

Render Markdown in Angular 17+ with standalone components, custom components, plugins, and streaming for AI output.

The @comark/angular package provides standalone components for rendering Comark content in Angular with full support for custom components, plugins, and streaming.

Requirements

DependencyVersion
Angular>=17.0.0 <22.0.0
TypeScript>=5.5.0
@comark/angular is bundler-agnostic: it works with the Angular CLI, Vite (via @analogjs/vite-plugin-angular), Webpack, esbuild, or any other build tool that supports Angular.

Installation

pnpm add @comark/angular

<comark-markdown>

The <comark-markdown> component is the quickest way to render markdown in Angular. It handles parsing and rendering automatically. The value input accepts a markdown string or a pre-parsed MarkdownDocument.

app.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" />`,
})
export class AppComponent {
  content = `# Hello World

This is **markdown** with Comark components.
`
}
Passing a document to <comark-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 <comark-markdown-document> instead.

Props (inputs)

InputTypeDefaultDescription
valuestring | MarkdownDocument''Markdown string or pre-parsed document
optionsParserOptions{}Parser options (autoUnwrap, autoClose, etc.)
pluginsComarkPlugin[][]Array of plugins
unwrapboolean | string | string[]falseStrip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, for example "ul li"
componentsRecord<string, Type<any>>{}Custom Angular component mappings
streamingbooleanfalseEnable streaming mode
summarybooleanfalseOnly render content before <!-- more -->
caretboolean | { class: string }falseAppend caret to last text node
dataRecord<string, unknown>{}Runtime values referenced from markdown via :prop="data.path"
transformNodeNodeTransform | NodeTransform[]undefinedChange the tag or props of element nodes right before they render

options

See ParserOptions for available options.

app.component.ts
@Component({
  template: `<comark-markdown [value]="content" [options]="{ autoUnwrap: true, autoClose: true }" />`,
})

plugins

See ComarkPlugin for available plugins.

app.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import shiki from '@comark/angular/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" [plugins]="plugins" />`,
})
export class AppComponent {
  content = '```js\nconsole.log("hello")\n```'
  plugins = [
    shiki({
      themes: { light: githubLight, dark: githubDark },
    }),
  ]
}

For math and mermaid plugins, also pass the companion components:

app.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import math, { Math } from '@comark/angular/plugins/math'
import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `
    <comark-markdown
      [value]="content"
      [plugins]="plugins"
      [components]="components"
    />
  `,
})
export class AppComponent {
  content = '$E = mc^2$'
  plugins = [math(), mermaid()]
  components = { Math, Mermaid }
}

components

Use this input to map custom Angular components to Comark elements and use them in your markdown.

Create a component

components/alert.component.ts
import { Component, Input } from '@angular/core'

@Component({
  selector: 'app-alert',
  standalone: true,
  template: `
    <div class="alert" [class]="'alert-' + type" role="alert">
      <ng-content />
    </div>
  `,
})
export class AlertComponent {
  @Input() type: 'info' | 'warning' | 'error' | 'success' = 'info'
}

Map the tag to your component

app.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import { AlertComponent } from './components/alert.component'
import { CardComponent } from './components/card.component'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" [components]="components" />`,
})
export class AppComponent {
  content = '...'
  components = { alert: AlertComponent, card: CardComponent }
}

Use it in your Markdown

::alert{type="warning"}
This is a warning message!
::
See Component Bindings for how props map to your Angular component inputs, or Component Syntax for the full Comark syntax API: nested components, inline syntax, and more.

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.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" [data]="data" />`,
})
export class AppComponent {
  data = { user: { name: 'Ada', role: 'admin' } }
  content = `Hello, :badge{:label="data.user.name"}!`
}

transformNode

Change an element's tag or props right before it renders, using values that are only known at render time. The function receives the node's tag, its resolved props, and the render context. It returns a new tag, new props, or nothing to keep the node as it is. See Transforming nodes before render for the full rules.

app.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import type { NodeTransform } from '@comark/angular'

const user = { id: 42 }

// Render every `::cta` as a link to the user's dashboard
const transformNode: NodeTransform = ({ tag, props }) => {
  if (tag !== 'cta') return
  return { tag: 'a', props: { ...props, href: `/users/${user.id}/dashboard` } }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" [transformNode]="transformNode" />`,
})
export class AppComponent {
  transformNode = transformNode
  content = `::cta\nOpen your dashboard\n::`
}

Comark renders what the function returns as-is, without sanitizing it, and the security plugin doesn't apply to it. Validate anything you build from untrusted data, such as URLs from data.

Keep the function on a field instead of creating it in the template, so change detection sees the same input. Angular can call it twice for a node that renders as a custom component, so keep it free of side effects.


<comark-markdown-document>

Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser.

Parsing

Parse your markdown content and pass the document directly:

docs.component.ts
import { Component, OnInit } from '@angular/core'
import { MarkdownDocument } from '@comark/angular'
import { parseMarkdown } from 'comark'
import type { MarkdownDocument as MarkdownDocumentType } from 'comark'

@Component({
  selector: 'app-docs',
  standalone: true,
  imports: [MarkdownDocument],
  template: `
    @if (document) {
      <comark-markdown-document [value]="document" [components]="components" />
    }
  `,
})
export class DocsComponent implements OnInit {
  document: MarkdownDocumentType | null = null
  components = {}

  async ngOnInit() {
    const markdown = await fetch('/api/content').then((r) => r.text())
    this.document = await parseMarkdown(markdown)
  }
}

Renderer props (inputs)

InputTypeDefaultDescription
valueMarkdownDocument—Required. The parsed document returned by parseMarkdown()
componentsRecord<string, Type<any>>{}Custom Angular component mappings
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend a blinking caret to the last text node
dataRecord<string, unknown>{}Runtime values referenced from markdown via :prop="data.path"
transformNodeNodeTransform | NodeTransform[]undefinedChange the tag or props of element nodes right before they render

Component bindings

Comark automatically bridges the gap between Comark syntax and your Angular component's interface.

Prop binding

Attributes in Comark syntax are passed as @Input() values to your component. Use the : prefix to pass typed values:

MarkdownAngular Input value
{type="warning"}"warning" (string)
{:count="5"}5 (number)
{:active="true"}true (boolean)
{:config='{"key":"val"}'}{ key: 'val' } (object)

Content projection

Default content in Comark maps to Angular's content projection (<ng-content />):

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ title }}</h3>
      <div class="card-body">
        <ng-content />
      </div>
    </div>
  `,
})
export class CardComponent {
  @Input() title = ''
}

Overriding HTML elements

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

Create an override component

components/prose-h1.component.ts
import { Component, Input } from '@angular/core'

@Component({
  selector: 'prose-h1',
  standalone: true,
  template: `
    <h1 [id]="id" class="custom-heading">
      <ng-content />
    </h1>
  `,
})
export class ProseH1Component {
  @Input() id?: string
}

Map it via the components input

app.component.ts
@Component({
  template: `<comark-markdown [value]="content" [components]="components" />`,
})
export class AppComponent {
  components = { ProseH1: ProseH1Component }
}

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.


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/ai-chat.component.ts
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'

@Component({
  selector: 'app-ai-chat',
  standalone: true,
  imports: [Markdown],
  template: `
    <comark-markdown
      [value]="content"
      [streaming]="isStreaming"
      [caret]="true"
    />
  `,
})
export class AiChatComponent {
  content = ''
  isStreaming = false

  async askAI(prompt: string) {
    this.content = ''
    this.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
      this.content += decoder.decode(value, { stream: true })
    }

    this.isStreaming = false
  }
}
autoClose is enabled by default: incomplete syntax like **bold text is automatically closed on every parse. Disable with [options]="{ autoClose: false }".

Caret

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

<!-- Default caret -->
<comark-markdown [value]="content" [streaming]="isStreaming" [caret]="true" />

<!-- Custom caret class -->
<comark-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

Use ComarkPlugin from comark to type plugin arrays, and ElementNode to type the __node input in components that override HTML elements:

comark-wrapper.component.ts
import { Component, Input, Type } from '@angular/core'
import { Markdown } from '@comark/angular'
import type { ComarkPlugin } from 'comark'

@Component({
  selector: 'app-comark-wrapper',
  standalone: true,
  imports: [Markdown],
  template: `<comark-markdown [value]="content" [components]="components" [plugins]="plugins" />`,
})
export class ComarkWrapperComponent {
  @Input() content = ''
  @Input() components: Record<string, Type<any>> = {}
  @Input() plugins: ComarkPlugin[] = []
}
components/heading.component.ts
import { Component, Input } from '@angular/core'
import type { ElementNode } from 'comark'

@Component({
  selector: 'app-heading',
  standalone: true,
  template: `
    <h2 [id]="id" class="heading">
      <ng-content />
    </h2>
  `,
})
export class HeadingComponent {
  @Input() __node?: ElementNode
  @Input() id?: string
}

Pre-configured components

Use defineMarkdownComponent and defineMarkdownDocumentComponent to create pre-configured wrappers with default plugins, components, and styling baked in, so there is no need to repeat the same config on every instance.

defineMarkdownComponent

components/docs-markdown.ts
import { defineMarkdownComponent } from '@comark/angular'
import shiki from '@comark/angular/plugins/shiki'
import math, { Math } from '@comark/angular/plugins/math'
import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid'

export const DocsMarkdown = defineMarkdownComponent({
  plugins: [shiki(), math(), mermaid()],
  components: { Math, Mermaid },
  class: 'prose dark:prose-invert',
})

Then use it like any other component. The returned component always uses the comark-markdown-defined selector:

page.component.ts
import { Component } from '@angular/core'
import { DocsMarkdown } from './components/docs-markdown'

@Component({
  selector: 'app-page',
  standalone: true,
  imports: [DocsMarkdown],
  template: `<comark-markdown-defined [value]="content" />`,
})
export class PageComponent {
  content = '# Hello\n\n$E = mc^2$'
}

Instance-level [plugins] and [components] inputs are merged with (and override) the config-level defaults. Both defineMarkdownComponent and defineMarkdownDocumentComponent also accept transformNode, which runs before the instance's [transformNode] input.

defineMarkdownDocumentComponent

Same idea, but for the low-level renderer. The returned component always uses the comark-markdown-document-defined selector:

components/docs-markdown-document.ts
import { defineMarkdownDocumentComponent } from '@comark/angular'
import { Math } from '@comark/angular/plugins/math'

export const DocsMarkdownDocument = defineMarkdownDocumentComponent({
  components: { Math },
  class: 'prose',
})

© 2026 Vercel, Inc.