Custom Tags & Advanced Components
Use this page when Markdown needs to contain trusted, component-like tags such as thinking, answer-box, or other domain-specific blocks.
The recommended path is:
- register the tag with
custom-html-tags - map the resulting node type with
setCustomComponents - keep the mapping scoped with
custom-id
Reach for parser hooks only after this flow stops being enough.
If these tags live inside a docs site or VitePress theme, pair this page with Docs Site & VitePress so the theme-level registration and CSS order stay in one place.
1. The simplest custom-tag setup
import { setCustomComponents } from 'markstream-vue'
import ThinkingNode from './ThinkingNode.vue'
setCustomComponents('chat', {
thinking: ThinkingNode,
})<template>
<MarkdownRender
custom-id="chat"
:custom-html-tags="['thinking']"
:content="markdown"
/>
</template>Once the tag is allowlisted, the parser emits a custom node whose type is the tag name itself.
2. A practical Vue component for nested content
Custom tags usually contain Markdown inside them. The easiest way to preserve that nested Markdown is to render the tag body with another MarkdownRender.
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
const props = defineProps<{
node: {
type: 'thinking'
content?: string
loading?: boolean
}
customId?: string
isDark?: boolean
}>()
</script>
<template>
<section class="thinking-box" :data-loading="props.node.loading || undefined">
<header class="thinking-box__title">
Thinking
</header>
<MarkdownRender
:content="String(props.node.content ?? '')"
:custom-id="props.customId"
:is-dark="props.isDark"
:custom-html-tags="['thinking']"
:typewriter="false"
:viewport-priority="false"
:defer-nodes-until-visible="false"
:max-live-nodes="0"
:batch-rendering="false"
/>
</section>
</template>That nested-renderer pattern is also what keeps repeated and nested custom tags predictable.
3. What the parser gives you
For a trusted custom tag, the emitted node typically includes:
type: the tag name, for examplethinkingtag: the original tag namecontent: the inner Markdown or text contentattrs: extracted tag attributes when availableloading: whether the tag is still in a streaming mid-stateautoClosed: whether the parser temporarily auto-closed the tag during streaming
The exact attrs shape can vary, so treat it as raw attribute data that your component normalizes for its own needs.
4. Repeated and nested custom tags
This flow is designed to support:
- repeated tags in the same document
- nested custom tags
- streaming mid-states while the closing tag has not arrived yet
Tips that help in practice:
- pass the same
custom-html-tagslist into nested renderers - disable batching and viewport deferral inside small nested shells when you want the most predictable streaming behavior
- keep the outer renderer scoped with
custom-id
5. When custom-html-tags is enough, and when it is not
Use custom-html-tags plus setCustomComponents when:
- the syntax is already tag-like
- you trust the source
- you mainly need a different renderer, not a different grammar
Move to Advanced Parser Hooks when:
- tags need token rewriting before they become stable nodes
- you must merge, split, or reshape nodes after parsing
- the source format is not well represented by a tag-like wrapper
6. Scope and cleanup still matter
Even for custom tags, prefer scoped mappings:
import { removeCustomComponents, setCustomComponents } from 'markstream-vue'
setCustomComponents('chat', { thinking: ThinkingNode })
// later, when the scoped renderer is no longer needed
removeCustomComponents('chat')This keeps custom behavior local to the page, route, or app surface that actually needs it.