Skip to content

Vanilla

@domternal/vanilla provides framework-free DOM components for the Domternal editor. Use it in Astro, Svelte, Solid, plain HTML, Web Components - anywhere without a framework runtime. The API is class-based: new DomternalEditor(host, opts) returns an instance with public getters and setters, EventTarget events, and a .destroy() method that cleans up via AbortController.

Click to try it out
Live vanilla editor powered by @domternal/vanilla

The package ships six classes:

ClassPurpose
DomternalEditorThe editor itself (wraps Editor from core)
DomternalToolbarTop toolbar with extension-driven buttons
DomternalBubbleMenuFloating menu on text selection
DomternalFloatingMenuBlock-insert menu on empty paragraphs
DomternalEmojiPickerEmoji picker panel
DomternalNotionColorPickerNamed-token color picker (Notion-style)

Plus shared utilities (isBrowser, assertBrowser, createPluginKey, renderIconInto, resolveIcon, subscribe) and helper exports for power users.

Terminal window
pnpm add @domternal/core @domternal/theme @domternal/vanilla
  1. <div id="toolbar"></div>
    <div id="editor" class="dm-editor"></div>
    <div id="bubble" class="dm-bubble-menu"></div>
  2. import { StarterKit, BubbleMenu } from '@domternal/core';
    import {
    DomternalEditor,
    DomternalToolbar,
    DomternalBubbleMenu,
    } from '@domternal/vanilla';
    import '@domternal/theme';
    const toolbarEl = document.getElementById('toolbar')!;
    const editorEl = document.getElementById('editor')!;
    const bubbleEl = document.getElementById('bubble')!;
    const dm = new DomternalEditor(editorEl, {
    extensions: [StarterKit, BubbleMenu.configure({ element: bubbleEl })],
    content: '<p>Hello world</p>',
    });
    new DomternalToolbar(toolbarEl, { editor: dm.editor });
    new DomternalBubbleMenu(bubbleEl, { editor: dm.editor });
  3. Each class exposes a .destroy() method that cleans up listeners, plugins, and DOM. Call it before navigation in SPAs, when unmounting from a framework, or when finished with the editor:

    dm.destroy();

    Single-page apps that swap content between routes must call destroy() on every instance or the editor leaks plugins and event listeners.

Every class follows the same pattern:

const instance = new DomternalX(host, options);
// host: HTMLElement - the mount point
// options: required + optional fields, depends on the class
instance.someGetter; // read state
instance.setSomeOption(value); // update reactive options
instance.addEventListener('eventname', handler); // listen to changes
instance.destroy(); // cleanup, idempotent

Each class extends EventTarget so you can listen for state changes via addEventListener. All listeners attached internally use an AbortController scoped to the instance - calling destroy() aborts the signal and removes them in one operation.


const dm = new DomternalEditor(host, options);

The editor itself. Wraps a new Editor({ element: host, ...options }) from core, exposes content getters, callback options, and EventTarget events. The underlying Editor instance is exposed via dm.editor.

interface DomternalEditorOptions {
extensions?: AnyExtension[]; // merged on top of DEFAULT_EXTENSIONS
content?: Content; // initial HTML string or JSON
editable?: boolean; // default true
autofocus?: FocusPosition; // default false
outputFormat?: 'html' | 'json'; // default 'html'
onCreate?: (editor: Editor) => void;
onUpdate?: (ctx: { editor: Editor }) => void;
onSelectionChange?: (ctx: { editor: Editor }) => void;
onFocus?: (ctx: { editor: Editor; event: FocusEvent }) => void;
onBlur?: (ctx: { editor: Editor; event: FocusEvent }) => void;
onDestroy?: () => void;
}
MemberTypeDescription
editorEditor (readonly)Underlying ProseMirror Editor
hostHTMLElement (readonly)Host element passed to constructor
htmlContentstring (getter)Current editor HTML
jsonContentJSONContent (getter)Current editor JSON
isEmptyboolean (getter)True if document is empty
isFocusedboolean (getter)True if editor has focus
isEditableboolean (getter)Current editable state
setContent(content, emitUpdate?)methodReplace content
setEditable(editable)methodToggle read-only
focus(position?)methodProgrammatic focus
destroy()methodTear down, idempotent
dm.addEventListener('create', (e) => /* { editor } */);
dm.addEventListener('update', (e) => /* { editor } */);
dm.addEventListener('selectionchange', (e) => /* { editor } */);
dm.addEventListener('focus', (e) => /* { editor, event } */);
dm.addEventListener('blur', (e) => /* { editor, event } */);
dm.addEventListener('destroy', (e) => /* null */);

All events are CustomEvents. Access the payload via e.detail.

If you pass extensions: undefined (or omit it), DomternalEditor uses a sensible default list. Pass your own array to override. Extensions you pass are merged on top of the defaults.

import { DEFAULT_EXTENSIONS } from '@domternal/vanilla';

const toolbar = new DomternalToolbar(host, options);

Renders a toolbar with extension-driven buttons. Internally uses ToolbarController from core; you can read it via toolbar.controller for advanced cases.

interface DomternalToolbarOptions {
editor: Editor; // required
icons?: IconSet; // icon overrides
layout?: ToolbarLayoutEntry[]; // custom layout (subset/order)
customContent?: HTMLElement; // append your own DOM after default items
}
MemberTypeDescription
hostHTMLElement (readonly)Host element
editorEditor (readonly)Bound editor
controllerToolbarController (readonly)Underlying controller
openDropdownstring | null (getter)Currently open dropdown name
setLayout(layout?)methodReplace layout
setIcons(icons?)methodReplace icon set
closeDropdown()methodClose any open dropdown
destroy()methodTear down, idempotent
toolbar.addEventListener('dropdownopen', (e) => /* { name } */);
toolbar.addEventListener('dropdownclose', (e) => /* { name } */);

const bubble = new DomternalBubbleMenu(host, options);

Floating contextual menu on text selection. Renders formatting buttons + Notion-mode trailing buttons (A color trigger, … block menu trigger) when those extensions are loaded.

interface DomternalBubbleMenuOptions {
editor: Editor; // required
shouldShow?: BubbleMenuOptions['shouldShow']; // override visibility
placement?: 'top' | 'bottom'; // default 'top'
offset?: number; // default 8
updateDelay?: number; // default 0
items?: string[]; // explicit item list, e.g. ['bold', 'italic', '|', 'link']
contexts?: Record<string, string[] | true | null>; // context-aware items (e.g. { image: true })
icons?: IconSet; // NEW in v0.7.0
customContent?: HTMLElement; // append custom DOM after default items + trailing
}
MemberTypeDescription
hostHTMLElement (readonly)Host element
editorEditor (readonly)Bound editor
trailingReadonly<BubbleMenuTrailingState> (getter)Current trailing-button state
openDropdownstring | null (getter)Currently open dropdown (e.g. text-align)
openColorPicker(anchor)methodEmit notionColorOpen event
openBlockContextMenu(anchor)methodDispatch dm:block-context-menu-open
setItems(items?)methodReplace explicit item list
setContexts(contexts?)methodReplace context map
setIcons(icons?)methodReplace icon set
closeDropdown()methodClose text-align dropdown
destroy()methodTear down, idempotent
interface BubbleMenuTrailingState {
isNodeSelection: boolean;
showColorPickerButton: boolean;
showBlockMenuButton: boolean;
blockMenuButtonDisabled: boolean;
currentTextColorVar: string | null;
currentBgColorVar: string | null;
hasAnyColor: boolean;
}
bubble.addEventListener('dropdownopen', (e) => /* { name } */);
bubble.addEventListener('dropdownclose', (e) => /* { name } */);

const floating = new DomternalFloatingMenu(host, options);

Block-insert menu shown on empty paragraphs. Renders items contributed via addFloatingMenuItems() from extensions.

interface DomternalFloatingMenuOptions {
editor: Editor; // required
shouldShow?: FloatingMenuOptions['shouldShow']; // override visibility
offset?: number; // default 0
items?: FloatingMenuItemsOverride; // override default items
keymap?: FloatingMenuKeymap; // default { enterMenu: ['Alt-F10', 'Mod-/'] }
icons?: IconSet; // icon overrides
requireExplicitTrigger?: boolean; // default false. Notion mode = true
customContent?: HTMLElement; // when set, consumer owns rendering
}
MemberTypeDescription
hostHTMLElement (readonly)Host element
editorEditor (readonly)Bound editor
controllerFloatingMenuController | null (readonly)Underlying controller (null when customContent is used)
setIcons(icons?)methodReplace icon set
destroy()methodTear down, idempotent

Roving tabindex pattern - only the focused item is in Tab order. Inside the menu: Arrow Up/Down (wraps), Home/End, Enter/Space (execute), Escape (leave). Enter the menu via the keymap.enterMenu shortcuts (default Alt-F10, Mod-/).

See Floating Menu for the full items API.


const picker = new DomternalEmojiPicker(host, options);
interface DomternalEmojiPickerOptions {
editor: Editor; // required
emojis: EmojiPickerItem[]; // required - full emoji set
customContent?: HTMLElement; // append custom DOM
}
interface EmojiPickerItem {
emoji: string;
name: string;
group: string;
}
MemberTypeDescription
hostHTMLElement (readonly)Host element
editorEditor (readonly)Bound editor
isOpenboolean (getter)Current open state
searchQuerystring (getter)Current search filter
activeCategorystring (getter)Currently selected category tab
open(anchor)methodOpen against anchor, or unpositioned if null
close()methodClose + focus editor view
destroy()methodTear down, idempotent
picker.addEventListener('openchange', (e) => /* { isOpen } */);
picker.addEventListener('select', (e) => /* { name, emoji } */);

The picker also listens for the insertEmoji editor event (from extension-emoji) with toggle-open semantics: if open, closes; if closed, opens.


const picker = new DomternalNotionColorPicker(options);
interface DomternalNotionColorPickerOptions {
editor: Editor; // required - the editor instance
}
MemberTypeDescription
panelHTMLDivElement | null (readonly)Panel element (created lazily on first open)
hostHTMLElement | null (readonly)Auto-resolved .dm-editor host (null if not in DOM)
editorEditor (readonly)Bound editor
isOpenboolean (getter)Current open state
currentTextTokenstring | null (getter)Active text color token from selection
currentBgTokenstring | null (getter)Active background color token
palettereadonly string[] (getter)Named-token palette
open(anchor)methodOpen against anchor (toggle on same anchor)
close(opts?)methodClose picker. { refocus: true } focuses editor view.
applyText(token)methodApply text color token (null clears)
applyBg(token)methodApply background color token (null clears)
tokenLabel(token)methodDisplay label for a palette token (title-case fallback)
destroy()methodTear down, idempotent
picker.addEventListener('openchange', (e) => /* { isOpen } */);
picker.addEventListener('apply', (e) => /* { kind: 'text'|'bg', token: string|null } */);

The picker listens for the notionColorOpen editor event from DomternalBubbleMenu’s A trigger.


Every constructor calls assertBrowser() first, which throws if typeof window === 'undefined'. Module-scope code is SSR-safe - only constructor bodies and methods touch the DOM, so import { DomternalEditor } from '@domternal/vanilla' succeeds during server-side rendering.

For Astro:

src/pages/editor.astro
<div id="toolbar"></div>
<div id="editor" class="dm-editor"></div>
<div id="bubble" class="dm-bubble-menu"></div>
<script>
import { StarterKit, BubbleMenu } from '@domternal/core';
import { DomternalEditor, DomternalToolbar, DomternalBubbleMenu } from '@domternal/vanilla';
import '@domternal/theme';
const editorEl = document.getElementById('editor')!;
const toolbarEl = document.getElementById('toolbar')!;
const bubbleEl = document.getElementById('bubble')!;
const dm = new DomternalEditor(editorEl, {
extensions: [StarterKit, BubbleMenu.configure({ element: bubbleEl })],
content: '<p>Hello from Astro!</p>',
});
new DomternalToolbar(toolbarEl, { editor: dm.editor });
new DomternalBubbleMenu(bubbleEl, { editor: dm.editor });
</script>

The <script> block in Astro is client-side by default. For component-style integration with client:* directives, wrap the vanilla code in your own component and use client:only="any" or client:load.


Most classes accept a customContent: HTMLElement option for users who want to render their own DOM instead of (or appended to) the default UI.

const myCustom = document.createElement('div');
myCustom.innerHTML = '<button class="my-btn">Custom action</button>';
myCustom.querySelector('.my-btn')!.addEventListener('click', () => {
// your logic
});
const bubble = new DomternalBubbleMenu(host, {
editor: dm.editor,
customContent: myCustom,
});

The DOM you pass remains YOUR responsibility - clean up your event listeners when the wrapper is destroyed. The wrapper appends customContent to its host but does not mutate it. If you need dynamic updates, listen to the wrapper’s EventTarget events and mutate your DOM yourself.


The package exports a barrel (@domternal/vanilla) AND per-component subpaths. Use subpaths for the smallest possible bundle:

// Barrel (loads all 6 classes, tree-shaking removes unused)
import { DomternalEditor, DomternalToolbar } from '@domternal/vanilla';
// Subpath imports (smallest)
import { DomternalEditor } from '@domternal/vanilla/editor';
import { DomternalToolbar } from '@domternal/vanilla/toolbar';
import { DomternalBubbleMenu } from '@domternal/vanilla/bubble-menu';
import { DomternalFloatingMenu } from '@domternal/vanilla/floating-menu';
import { DomternalEmojiPicker } from '@domternal/vanilla/emoji-picker';
import { DomternalNotionColorPicker } from '@domternal/vanilla/notion-color-picker';

The package is marked sideEffects: false so unused subpaths are eliminated by any modern bundler (Vite, esbuild, Rollup).


  • Before navigating away in a single-page app
  • When unmounting from a framework that re-renders parts of the page
  • In tests, between scenarios
  • Whenever you’re done with the editor instance

.destroy() is idempotent - calling it twice is a no-op.

Every instance creates one AbortController and passes { signal: this.#abortCtl.signal } to every addEventListener call. Calling .destroy() calls this.#abortCtl.abort(), which removes ALL listeners in one operation. No manual removeEventListener calls are needed and no leaks are possible from forgotten listeners.

Plugins registered with the editor are explicitly unregistered. Floating UI cleanup callbacks are invoked. DOM trees mounted into the host are removed.

Two instances mounted in the same editor (rare but possible) get distinct PluginKey suffixes via createPluginKey() (which uses crypto.randomUUID() with a Math.random fallback). Each destroy() is independent.


The Vanilla wrapper has full parity with Angular / React / Vue for Notion-mode features. See the Notion Mode guide for the cross-framework setup including:

  • Required extensions list (@domternal/extension-block-menu, @domternal/extension-toc, NotionColorPicker, BlockColor, UniqueID, ListIndent)
  • .dm-notion-mode CSS class
  • requireExplicitTrigger: true on DomternalFloatingMenu
  • Cross-framework configuration cookbook

For power users and framework adapters:

import {
isBrowser, // boolean: typeof window !== 'undefined'
assertBrowser, // throws if not browser
createPluginKey, // creates a unique PluginKey (crypto.randomUUID + fallback)
renderIconInto, // safe innerHTML helper for icon SVG strings
resolveIcon, // IconSet lookup with defaultIcons fallback
subscribe, // EventTarget subscribe helper
} from '@domternal/vanilla';
import type { CustomContentOption } from '@domternal/vanilla';

These are exposed so framework adapters can compose them into framework-specific components without depending on internal paths.


  • Notion Mode guide - full Notion-style editor setup with extension-block-menu + extension-toc + theme class
  • StackBlitz Example - working vanilla editor with all extensions
  • Editor API - commands, events, content getters - same as headless usage
  • Theming - CSS custom properties