Skip to content

Vue

The @domternal/vue package provides composable components and hooks that wrap the headless editor with Vue-native APIs. Works with Vue 3.3+ and uses the Composition API throughout.

Click to try it out
Vanilla JS preview - Vue components produce the same output
Terminal window
pnpm add @domternal/core @domternal/theme @domternal/vue

Import the theme in your main entry file or component:

main.ts
import '@domternal/theme';

The recommended pattern uses the composable Domternal component with namespaced subcomponents:

<script setup lang="ts">
import { Domternal } from '@domternal/vue';
import { StarterKit, BubbleMenu } from '@domternal/core';
const extensions = [StarterKit, BubbleMenu];
</script>
<template>
<Domternal :extensions="extensions" content="<p>Hello from Vue!</p>">
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.BubbleMenu
:contexts="{ text: ['bold', 'italic', 'underline'] }"
/>
</Domternal>
</template>

Domternal creates the editor and provides it to all subcomponents via Vue’s provide/inject. No need to pass editor props manually.

The Domternal component handles editor creation and context internally. Subcomponents access the editor automatically:

<template>
<Domternal :extensions="extensions" :content="content">
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.BubbleMenu />
<Domternal.EmojiPicker :emojis="emojis" />
</Domternal>
</template>

Use useEditor when you need direct access to the editor instance or custom rendering:

<script setup lang="ts">
import { useEditor, provideEditor, DomternalToolbar } from '@domternal/vue';
import { StarterKit } from '@domternal/core';
const { editor, editorRef } = useEditor({
extensions: [StarterKit],
content: '<p>Hello</p>',
});
provideEditor(editor);
</script>
<template>
<DomternalToolbar v-if="editor" />
<div class="dm-editor">
<div ref="editorRef" />
</div>
</template>

Core composable for creating and managing an editor instance.

const { editor, editorRef } = useEditor(options);
OptionTypeDefaultDescription
extensionsAnyExtension[][]Extensions to load. Default extensions (Document, Paragraph, Text, BaseKeymap, History) are always included.
contentContent''Initial content (HTML string or JSON). Reactive: changing this syncs the editor.
editablebooleantrueWhether the editor is editable. Reactive.
autofocusFocusPositionfalseFocus on mount: true, 'start', 'end', 'all', or a position number.
outputFormat'html' | 'json''html'Format used for content comparison when syncing.
immediatelyRenderbooleanfalseSet to true to create the editor synchronously during setup instead of waiting for onMounted.
onCreate(editor: Editor) => void-Called when the editor is created.
onUpdate(props: { editor: Editor }) => void-Called when the document content changes.
onSelectionChange(props: { editor: Editor }) => void-Called when the selection changes without content change.
onFocus(props: { editor: Editor; event: FocusEvent }) => void-Called when the editor gains focus.
onBlur(props: { editor: Editor; event: FocusEvent }) => void-Called when the editor loses focus.
onDestroy() => void-Called before the editor is destroyed.
PropertyTypeDescription
editorShallowRef<Editor | null>The editor instance. Null until onMounted (or immediately if immediatelyRender is true).
editorRefRef<HTMLDivElement | undefined>Template ref to attach to the editor mount point.

By default, useEditor creates the editor in onMounted, which never runs on the server. This makes it SSR-safe out of the box. Set immediatelyRender: true only when you’re certain your code doesn’t run on the server and you need synchronous editor creation.

When the extensions array reference changes, the editor is destroyed and recreated. Content is preserved automatically via JSON serialization.


Subscribe to editor state changes. Two overloads for different use cases.

Returns all state properties as individual refs. Updates when any state changes:

const { htmlContent, jsonContent, isEmpty, isFocused, isEditable } = useEditorState(editor);
PropertyTypeDescription
htmlContentRef<string>Current document as HTML.
jsonContentRef<JSONContent | null>Current document as JSON.
isEmptyRef<boolean>Whether the document is empty.
isFocusedRef<boolean>Whether the editor has focus.
isEditableRef<boolean>Whether the editor is editable.

Returns a computed ref with a derived value. Only recomputes when the editor state changes:

const isBold = useEditorState(editor, (ed) => ed.isActive('bold'));
const wordCount = useEditorState(editor, (ed) =>
ed.state.doc.textContent.split(/\s+/).filter(Boolean).length
);

The selector receives the full Editor instance and returns T | undefined. The return is undefined when the editor is null or destroyed.


Access the editor instance from the nearest Domternal or provideEditor ancestor:

<script setup lang="ts">
import { useCurrentEditor } from '@domternal/vue';
const { editor } = useCurrentEditor();
</script>
<template>
<button v-if="editor" @click="editor.chain().focus().toggleBold().run()">
Bold
</button>
</template>

Returns { editor: ShallowRef<Editor | null> }. Returns a ref with null if used outside a provider.


Provides the editor instance to all descendant components via Vue’s provide/inject:

import { useEditor, provideEditor } from '@domternal/vue';
const { editor } = useEditor({ extensions, content });
provideEditor(editor);

This is called automatically inside Domternal and DomternalEditor. You only need to call it manually when using useEditor directly, especially when using VueNodeViewRenderer (see Custom node views).

provideEditor also captures the Vue app context (including all ancestor provide values) and stores it for node view components. This is how provide/inject works across the ProseMirror boundary.

Composable root that creates an editor and provides it via inject. Accepts all useEditor options as props.

All useEditor options are accepted as props:

PropTypeDefaultDescription
extensionsAnyExtension[][]Extensions to load.
contentContent''Initial content (HTML string or JSON).
editablebooleantrueWhether the editor is editable. Reactive.
autofocusFocusPositionfalseFocus on mount.
outputFormat'html' | 'json''html'Format for content comparison.
immediatelyRenderbooleanfalseCreate editor synchronously.
onCreateFunction-Called when editor is created.
onUpdateFunction-Called on content changes.
onSelectionChangeFunction-Called on selection changes.
onFocusFunction-Called on focus.
onBlurFunction-Called on blur.
onDestroyFunction-Called before destroy.
ComponentPropsDescription
Domternal.Contentclass?: stringRenders the editor content area. Mounts the ProseMirror DOM.
Domternal.Loadingdefault slotRenders slot content only while editor is not ready (SSR loading state).
Domternal.Toolbaricons?: IconSet, layout?: ToolbarLayoutEntry[]Auto-rendering toolbar with optional custom icons and layout.
Domternal.BubbleMenuitems?, contexts?, shouldShow?, placement?, offset?, updateDelay?Context-aware bubble menu. See DomternalBubbleMenu.
Domternal.FloatingMenushouldShow?, offset?Floating menu on empty lines. See DomternalFloatingMenu.
Domternal.EmojiPickeremojis: EmojiPickerItem[]Searchable emoji picker. See DomternalEmojiPicker.

All subcomponents automatically use the editor from context. No editor prop needed.


All-in-one editor with integrated state management, context provider, and v-model support. Alternative to the composable pattern.

<script setup lang="ts">
import { ref } from 'vue';
import { DomternalEditor, DomternalToolbar } from '@domternal/vue';
import { StarterKit } from '@domternal/core';
const content = ref('<p>Hello</p>');
const editorRef = ref();
</script>
<template>
<DomternalEditor
ref="editorRef"
:extensions="[StarterKit]"
v-model="content"
>
<DomternalToolbar />
</DomternalEditor>
</template>

All useEditor options plus:

PropTypeDefaultDescription
classstring-CSS class for the .dm-editor wrapper.
outputFormat'html' | 'json''html'Format used by v-model and content comparison.
modelValueContent-v-model binding. When set, two-way syncs editor content.
EventPayloadDescription
update:modelValuestring | JSONContentEmitted on content changes. Format depends on outputFormat.

Access the editor instance and state via template ref:

PropertyTypeDescription
editorShallowRef<Editor | null>The editor instance.
htmlContentRef<string>Current HTML content.
jsonContentRef<JSONContent | null>Current JSON content.
isEmptyRef<boolean>Whether the document is empty.
isFocusedRef<boolean>Whether the editor has focus.
<script setup lang="ts">
import { ref } from 'vue';
import { DomternalEditor } from '@domternal/vue';
import { StarterKit } from '@domternal/core';
const content = ref('<p>Hello</p>');
</script>
<template>
<DomternalEditor
:extensions="[StarterKit]"
v-model="content"
/>
<pre>{{ content }}</pre>
</template>

By default, v-model syncs HTML strings. Set outputFormat="json" to sync JSON instead:

<DomternalEditor
:extensions="[StarterKit]"
v-model="content"
output-format="json"
/>

Auto-renders toolbar buttons, dropdowns, separators, and keyboard navigation based on the editor’s extensions.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance. Falls back to context if omitted.
iconsIconSetundefinedCustom icon set. Falls back to built-in defaultIcons.
layoutToolbarLayoutEntry[]undefinedCustom layout to reorder, group, or filter toolbar items.

By default, the toolbar renders all items grouped by their group property. Use layout to customize:

<script setup lang="ts">
import { Domternal } from '@domternal/vue';
import { StarterKit, BubbleMenu } from '@domternal/core';
import type { ToolbarLayoutEntry } from '@domternal/core';
const extensions = [StarterKit, BubbleMenu];
const layout: ToolbarLayoutEntry[] = [
'bold', 'italic', 'underline', '|',
'heading', '|',
'bulletList', 'orderedList',
];
</script>
<template>
<Domternal :extensions="extensions" content="<p>Hello</p>">
<Domternal.Toolbar :layout="layout" />
<Domternal.Content />
</Domternal>
</template>
KeyAction
ArrowRightFocus next button
ArrowLeftFocus previous button
ArrowDownOpen dropdown / focus next dropdown item
ArrowUpFocus previous dropdown item
HomeFocus first button
EndFocus last button
EscapeClose open dropdown

An inline formatting toolbar that appears when the user selects text.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
itemsstring[]-Fixed list of item names to show. Use `'
contextsRecord<string, string[] | true | null>-Context-aware items. Key is a node type name or 'text'/'table'. Use `'
shouldShow(props: { editor, view, state, from, to }) => boolean-Custom predicate to control visibility.
placement'top' | 'bottom''top'Menu placement relative to the selection.
offsetnumber8Pixel offset from the selection.
updateDelaynumber0Delay in milliseconds before updating position.

Show different buttons depending on what the user selected:

<Domternal.BubbleMenu
:contexts="{
text: ['bold', 'italic', 'underline', '|', 'link'],
heading: ['bold', 'italic', '|', 'link'],
codeBlock: null,
image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage'],
}"
/>
  • text - Default for text selections
  • null - Hide the menu in that context
  • true - Show all valid items for that context

When neither items nor contexts is provided:

  • Shows bold, italic, underline buttons on text selection
  • Automatically shows image/node-specific items when a node with registered bubbleMenu items is selected (e.g., image float/delete buttons)
  • Hidden inside table cells (tables have their own cell toolbar)

Add custom buttons via the default slot:

<Domternal.BubbleMenu :contexts="{ text: ['bold', 'italic'] }">
<button @click="handleCustomAction">Custom</button>
</Domternal.BubbleMenu>

A menu that appears on empty lines, allowing users to insert block-level content.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
shouldShow(props: { editor, view, state }) => boolean-Custom predicate to control visibility. Default: shows on empty paragraphs.
offsetnumber0Pixel offset from the cursor position.
<script setup lang="ts">
import { useCurrentEditor } from '@domternal/vue';
const { editor } = useCurrentEditor();
</script>
<template>
<Domternal.FloatingMenu>
<button @click="editor?.chain().focus().setHeading({ level: 1 }).run()">H1</button>
<button @click="editor?.chain().focus().toggleBulletList().run()">List</button>
<button @click="editor?.chain().focus().setHorizontalRule().run()">Divider</button>
</Domternal.FloatingMenu>
</template>

A searchable emoji picker panel that opens from the toolbar emoji button.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
emojisEmojiPickerItem[]requiredArray of emoji definitions with emoji, name, and group properties. Import the type from @domternal/vue.
<script setup lang="ts">
import { Domternal } from '@domternal/vue';
import { StarterKit } from '@domternal/core';
import { Emoji, emojis } from '@domternal/extension-emoji';
import type { EmojiPickerItem } from '@domternal/vue';
const extensions = [StarterKit, Emoji];
</script>
<template>
<Domternal :extensions="extensions" content="<p>Hello</p>">
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.EmojiPicker :emojis="emojis" />
</Domternal>
</template>

The picker opens when the user clicks the emoji toolbar button. It includes search, category tabs, frequently used section, grid keyboard navigation, and smooth scrolling.

Low-level component that mounts the ProseMirror DOM into a div. Used with the useEditor composable when you need full control over the layout.

<script setup lang="ts">
import { useEditor, EditorContent } from '@domternal/vue';
import { StarterKit } from '@domternal/core';
const { editor } = useEditor({
extensions: [StarterKit],
content: '<p>Hello</p>',
});
</script>
<template>
<EditorContent :editor="editor" class="dm-editor" />
</template>
PropTypeDefaultDescription
editorEditor | nullnullThe editor instance to render.
classstring-CSS class on the container.

Render Vue components inside the editor document. This lets you build interactive, editable nodes with full Vue lifecycle support and provide/inject access.

Converts a Vue component into a ProseMirror NodeView constructor:

CalloutView.vue
<script setup lang="ts">
import { NodeViewWrapper, NodeViewContent } from '@domternal/vue';
import type { VueNodeViewProps } from '@domternal/vue';
const props = defineProps<VueNodeViewProps>();
</script>
<template>
<NodeViewWrapper :class="`callout callout-${node.attrs['type']}`">
<select
:value="node.attrs['type']"
@change="updateAttributes({ type: ($event.target as HTMLSelectElement).value })"
contenteditable="false"
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<NodeViewContent />
</NodeViewWrapper>
</template>
Callout.ts
import { Node } from '@domternal/core';
import { VueNodeViewRenderer } from '@domternal/vue';
import CalloutView from './CalloutView.vue';
export const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'inline*',
addAttributes() {
return { type: { default: 'info' } };
},
parseHTML() {
return [{ tag: 'div[data-callout]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['div', { ...HTMLAttributes, 'data-callout': node.attrs['type'] }, 0];
},
addNodeView() {
return VueNodeViewRenderer(CalloutView);
},
});
PropTypeDescription
editorEditorThe editor instance.
nodePMNodeThe ProseMirror node being rendered.
selectedbooleanWhether this node is selected via NodeSelection.
getPos() => number | undefinedGet the document position of this node.
updateAttributes(attrs) => voidUpdate the node’s attributes.
deleteNode() => voidDelete this node from the document.
extension{ name, options }The extension that created this node view.
decorationsunknown[]ProseMirror decorations applied to this node.
OptionTypeDefaultDescription
asstring'div' / 'span'Wrapper element tag. Defaults to 'div' for block nodes, 'span' for inline.
classNamestring-CSS class on the wrapper element.
contentDOMElementstring | null'div'Tag for the content DOM element. Set to null for nodes with no editable content.

VueNodeViewRenderer forwards the Vue app context (provide/inject chain) from the parent component tree to node view components. This happens automatically when you use Domternal or call provideEditor() in the setup function.

Inside a node view component, you can use useCurrentEditor() and any other injected values from ancestor components:

<script setup lang="ts">
import { useCurrentEditor } from '@domternal/vue';
const { editor } = useCurrentEditor();
</script>

Container component for custom node views. Handles drag events and marks the element as a node view wrapper.

<NodeViewWrapper as="section" class="my-node">
<!-- Node view UI -->
</NodeViewWrapper>
PropTypeDefaultDescription
asstring'div'HTML element type to render.

Plus all standard HTML element attributes via v-bind="$attrs".

Placeholder for editable nested content. ProseMirror manages the DOM inside this element.

<NodeViewContent as="p" class="my-content" />
PropTypeDefaultDescription
asstring'div'HTML element type to render.

Plus all standard HTML element attributes via v-bind="$attrs".

Access node view context inside NodeViewWrapper and NodeViewContent:

import { useVueNodeView } from '@domternal/vue';
const { onDragStart, nodeViewContentRef } = useVueNodeView();
// onDragStart: drag event handler
// nodeViewContentRef: ref callback for content DOM

Vue’s onMounted never runs on the server, so useEditor is SSR-safe by default. The editor is null during server-side rendering and created after mount on the client.

Use Domternal.Loading to show a placeholder while the editor initializes:

<Domternal
:extensions="extensions"
content="<p>Hello</p>"
>
<Domternal.Loading>
<div>Loading editor...</div>
</Domternal.Loading>
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>

Domternal.Loading renders its slot content only while the editor is null. Once the editor is created on the client, the loading content disappears and the toolbar and content are rendered.

For SSR content generation without a browser, use the core SSR helpers:

import { generateHTML, generateJSON, generateText } from '@domternal/vue';
// These are re-exported from @domternal/core for convenience

Apply the theme class to a parent element:

<!-- Always dark -->
<div class="dm-theme-dark">
<Domternal :extensions="extensions">
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>
</div>
<!-- Follow system preference -->
<div class="dm-theme-auto">
<Domternal :extensions="extensions">
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>
</div>

Toggle at runtime:

function toggleTheme() {
document.body.classList.toggle('dm-theme-dark');
}

Replace the built-in Phosphor icons with your own:

<script setup lang="ts">
import { Domternal } from '@domternal/vue';
import { defaultIcons } from '@domternal/core';
import type { IconSet } from '@domternal/core';
const customIcons: IconSet = {
textB: '<svg>...</svg>',
textItalic: '<svg>...</svg>',
};
</script>
<template>
<Domternal.Toolbar :icons="customIcons" />
</template>

When icons is provided, the component uses only that set. To override just a few icons, spread defaultIcons:

import { defaultIcons } from '@domternal/core';
const myIcons = { ...defaultIcons, textB: '<svg><!-- custom bold --></svg>' };

Importing Domternal includes all subcomponents (Toolbar, BubbleMenu, FloatingMenu, EmojiPicker) in your bundle. For a smaller bundle, import only what you need:

// Compound - includes all subcomponents
import { Domternal } from '@domternal/vue';
// Standalone - only what you import ends up in your bundle
import { useEditor, DomternalToolbar, EditorContent } from '@domternal/vue';

See Packages & Bundle Size for details.

All components can be imported individually or used as Domternal subcomponents:

// Individual imports
import {
useEditor,
useEditorState,
useCurrentEditor,
provideEditor,
EDITOR_KEY,
Domternal,
DomternalEditor,
EditorContent,
DomternalToolbar,
DomternalBubbleMenu,
DomternalFloatingMenu,
DomternalEmojiPicker,
VueNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
useVueNodeView,
DEFAULT_EXTENSIONS,
} from '@domternal/vue';

Vue-specific types:

import type {
UseEditorOptions,
EditorState,
VueNodeViewProps,
VueNodeViewRendererOptions,
EmojiPickerItem,
} from '@domternal/vue';

Re-exported types from @domternal/core for convenience:

import type { Content, AnyExtension, FocusPosition, JSONContent } from '@domternal/vue';
import { Editor } from '@domternal/vue';
import { generateHTML, generateJSON, generateText } from '@domternal/vue';
  • Vue 3.3.0 or later
  • @domternal/core 0.6.0 or later