Toolbar
The toolbar system is a headless, framework-agnostic state machine that manages buttons, dropdowns, active states, disabled states, and keyboard navigation. Extensions declare their toolbar items via addToolbarItems(), and the controller handles everything else. Framework wrappers (Angular, React, Vue) bind to the controller to render the UI.
How it works
Section titled “How it works”- Each extension registers toolbar items via
addToolbarItems()during initialization - The editor collects all items into
editor.toolbarItems ToolbarControllergroups, sorts, and tracks the state of every item- On every editor transaction, the controller updates active and disabled states
- A framework component (or your own UI) reads the controller state and renders buttons
Extensions → addToolbarItems() → editor.toolbarItems → ToolbarController → UIQuick start
Section titled “Quick start”import { Editor, StarterKit, ToolbarController, defaultIcons,} from '@domternal/core';import '@domternal/theme';
const editorEl = document.getElementById('editor')!;
// Create toolbar elementconst toolbar = document.createElement('div');toolbar.className = 'dm-toolbar';toolbar.setAttribute('role', 'toolbar');editorEl.before(toolbar);
// Create editorconst editor = new Editor({ element: editorEl, extensions: [StarterKit], content: '<p>Hello world</p>',});
// Create controllerconst controller = new ToolbarController(editor, () => renderToolbar());controller.subscribe();
function renderToolbar() { toolbar.innerHTML = ''; for (const group of controller.groups) { const groupEl = document.createElement('div'); groupEl.className = 'dm-toolbar-group';
for (const item of group.items) { if (item.type !== 'button') continue; const btn = document.createElement('button'); btn.className = 'dm-toolbar-button'; btn.innerHTML = defaultIcons[item.icon] ?? ''; btn.title = item.label;
if (controller.activeMap.get(item.name)) { btn.classList.add('dm-toolbar-button--active'); } if (controller.disabledMap.get(item.name)) { btn.disabled = true; }
btn.addEventListener('click', () => { controller.executeCommand(item); editor.commands.focus(); });
groupEl.appendChild(btn); }
toolbar.appendChild(groupEl); }}
renderToolbar();This is the minimal approach. For a simpler setup without ToolbarController, see Getting Started which uses defaultIcons and manual event delegation.
import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalToolbarComponent,} from '@domternal/angular';import { Editor, StarterKit } from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent, DomternalToolbarComponent], template: ` @if (editor(); as ed) { <domternal-toolbar [editor]="ed" /> } <domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)" /> `,})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [StarterKit]; content = '<p>Hello world</p>';}The <domternal-toolbar> component handles everything automatically: rendering buttons from registered extensions, tracking active/disabled states, dropdown toggling, keyboard navigation, and icon rendering.
import { Domternal } from '@domternal/react';import { StarterKit } from '@domternal/core';
export default function Editor() { return ( <Domternal extensions={[StarterKit]} content="<p>Hello world</p>" > <Domternal.Toolbar /> <Domternal.Content /> </Domternal> );}The <Domternal.Toolbar /> component handles everything automatically: rendering buttons from registered extensions, tracking active/disabled states, dropdown toggling, keyboard navigation, and icon rendering.
Toolbar item types
Section titled “Toolbar item types”Extensions register three types of toolbar items: buttons, dropdowns, and separators.
ToolbarButton
Section titled “ToolbarButton”A button that executes a command when clicked.
{ type: 'button', name: 'bold', command: 'toggleBold', isActive: 'bold', icon: 'textB', label: 'Bold', shortcut: 'Mod-B', group: 'format', priority: 200,}| Property | Type | Default | Description |
|---|---|---|---|
type | 'button' | - | Item type identifier |
name | string | - | Unique identifier for this item |
command | string | - | Command name to execute (key of editor.commands) |
commandArgs | unknown[] | undefined | Arguments passed to the command |
icon | string | - | Icon key resolved from the icon set |
label | string | - | Tooltip text and aria-label |
shortcut | string | undefined | Keyboard shortcut shown in the tooltip |
group | string | '' | Group name for visual grouping (separators between groups) |
priority | number | 100 | Sort order within group (higher values appear first) |
isActive | string | object | array | undefined | How to check if this button is active (see Active state) |
isActiveFn | (editor) => boolean | undefined | Custom function for active state (takes precedence over isActive) |
style | string | undefined | Inline CSS applied to the button (e.g. 'font-family: Georgia' for font preview) |
emitEvent | string | undefined | Event name to emit instead of executing the command (see emitEvent buttons) |
color | string | undefined | Hex color for color-swatch rendering in grid dropdowns |
toolbar | boolean | true | Show in main toolbar. Set false for bubble-menu-only items |
bubbleMenu | string | undefined | Context name for auto-including in the bubble menu |
ToolbarDropdown
Section titled “ToolbarDropdown”A button with a dropdown panel containing multiple sub-buttons.
{ type: 'dropdown', name: 'heading', icon: 'textH', label: 'Heading', group: 'blocks', priority: 200, dynamicIcon: true, items: [ { type: 'button', name: 'paragraph', command: 'setParagraph', isActive: 'paragraph', icon: 'textT', label: 'Normal text' }, { type: 'button', name: 'heading1', command: 'toggleHeading', commandArgs: [{ level: 1 }], isActive: { name: 'heading', attributes: { level: 1 } }, icon: 'textHOne', label: 'Heading 1' }, { type: 'button', name: 'heading2', command: 'toggleHeading', commandArgs: [{ level: 2 }], isActive: { name: 'heading', attributes: { level: 2 } }, icon: 'textHTwo', label: 'Heading 2' }, ],}| Property | Type | Default | Description |
|---|---|---|---|
type | 'dropdown' | - | Item type identifier |
name | string | - | Unique identifier |
icon | string | - | Icon key for the trigger button |
label | string | - | Tooltip text and aria-label |
items | ToolbarButton[] | - | Buttons shown in the dropdown panel |
group | string | '' | Group name for visual grouping |
priority | number | 100 | Sort order within group |
layout | 'list' | 'grid' | 'list' | Panel layout: vertical list or color-swatch grid |
gridColumns | number | 10 | Number of columns in grid layout |
displayMode | 'icon-text' | 'text' | 'icon' | 'icon-text' | How to render items in the panel |
dynamicIcon | boolean | undefined | Trigger icon updates to reflect the active sub-item’s icon |
dynamicLabel | boolean | undefined | Trigger shows the active sub-item’s label as text instead of an icon |
dynamicLabelFallback | string | undefined | Text shown when dynamicLabel is true and no item is active |
computedStyleProperty | string | undefined | CSS property to read from cursor position when no item is active (e.g. 'font-size', 'font-family') |
defaultIndicatorColor | string | undefined | Fallback hex color for the trigger indicator bar (grid dropdowns only) |
ToolbarSeparator
Section titled “ToolbarSeparator”A visual divider between toolbar groups. Separators are inserted automatically between groups - you rarely need to register them manually.
{ type: 'separator' }| Property | Type | Default | Description |
|---|---|---|---|
type | 'separator' | - | Item type identifier |
name | string | undefined | Optional unique identifier |
group | string | undefined | Group name (for ordering) |
priority | number | 100 | Sort order within group |
Registering toolbar items
Section titled “Registering toolbar items”Extensions register toolbar items via the addToolbarItems() hook. Here is how the built-in Bold mark registers its button:
import { Mark } from '@domternal/core';import type { ToolbarItem } from '@domternal/core';
const Bold = Mark.create({ name: 'bold',
addToolbarItems(): ToolbarItem[] { return [ { type: 'button', name: 'bold', command: 'toggleBold', isActive: 'bold', icon: 'textB', label: 'Bold', shortcut: 'Mod-B', group: 'format', priority: 200, }, ]; },});The hook is called during ExtensionManager initialization. All returned items are collected into editor.toolbarItems. Items with toolbar: false are excluded from the main toolbar but still available for the bubble menu.
Registering a dropdown
Section titled “Registering a dropdown”Return a ToolbarDropdown with an array of ToolbarButton sub-items. This is how the built-in Heading node registers its dropdown:
addToolbarItems(): ToolbarItem[] { return [ { type: 'dropdown', name: 'heading', icon: 'textH', label: 'Heading', group: 'blocks', priority: 200, dynamicIcon: true, items: [ { type: 'button', name: 'paragraph', command: 'setParagraph', isActive: 'paragraph', icon: 'textT', label: 'Normal text', shortcut: 'Mod-Alt-0', }, { type: 'button', name: 'heading1', command: 'toggleHeading', commandArgs: [{ level: 1 }], isActive: { name: 'heading', attributes: { level: 1 } }, icon: 'textHOne', label: 'Heading 1', shortcut: 'Mod-Alt-1', }, { type: 'button', name: 'heading2', command: 'toggleHeading', commandArgs: [{ level: 2 }], isActive: { name: 'heading', attributes: { level: 2 } }, icon: 'textHTwo', label: 'Heading 2', shortcut: 'Mod-Alt-2', }, ], }, ];}Sub-items use commandArgs to pass arguments to the command and isActive with the object form to check for specific attributes.
Registering multiple items
Section titled “Registering multiple items”An extension can return multiple toolbar items. This is how History registers both undo and redo:
addToolbarItems(): ToolbarItem[] { return [ { type: 'button', name: 'undo', command: 'undo', icon: 'arrowCounterClockwise', label: 'Undo', shortcut: 'Mod-Z', group: 'history', priority: 200, }, { type: 'button', name: 'redo', command: 'redo', icon: 'arrowClockwise', label: 'Redo', shortcut: 'Mod-Shift-Z', group: 'history', priority: 190, }, ];}Neither button has isActive because undo and redo have no meaningful active state. They are disabled automatically when there is nothing to undo or redo (the can() dry-run returns false).
Registering an emitEvent button
Section titled “Registering an emitEvent button”Buttons that open a popover or dialog use emitEvent instead of executing a command directly. This is how Image registers its toolbar button:
addToolbarItems(): ToolbarItem[] { return [ { type: 'button', name: 'image', command: 'setImage', commandArgs: [{ src: '' }], icon: 'image', label: 'Insert Image', group: 'insert', priority: 150, emitEvent: 'insertImage', }, ];}When clicked, the toolbar emits insertImage on the editor instead of calling setImage. The command field is still needed for disabled state checking via can().
Registering a button with custom active state
Section titled “Registering a button with custom active state”Use isActiveFn for extensions that track state in storage rather than via node/mark state. This is how InvisibleChars registers its toggle button:
addToolbarItems(): ToolbarItem[] { return [ { type: 'button', name: 'invisibleChars', command: 'toggleInvisibleChars', icon: 'paragraph', label: 'Invisible Characters', group: 'utility', priority: 100, isActiveFn: (editor) => { const storage = editor.storage['invisibleChars'] as | { isVisible?: () => boolean } | undefined; return storage?.isVisible?.() ?? false; }, }, ];}isActiveFn takes precedence over isActive and receives the editor instance. Use it when the active state depends on extension storage or plugin state rather than on which node or mark is active at the cursor.
Groups and priority
Section titled “Groups and priority”Items are grouped by their group property. Within each group, items are sorted by priority (higher values appear first). A visual separator is inserted between groups automatically.
The built-in extensions use these group names and priorities:
| Group | Extensions | Priority range |
|---|---|---|
format | Bold (200), Italic (190), Underline (180), Strike (170), Code (160), Highlight (150), Subscript (140), Superscript (130), Link (120) | 200-120 |
blocks | Heading dropdown (200), Blockquote (150), CodeBlock (140), HorizontalRule (130) | 200-130 |
lists | BulletList (200), OrderedList (190), TaskList (170) | 200-170 |
textStyle | TextColor (200), FontFamily (150), FontSize (100), LineHeight (50) | 200-50 |
alignment | TextAlign dropdown (200) | 200 |
insert | Image (150), Table (140), Emoji (50), HardBreak (50) | 150-50 |
history | Undo (200), Redo (190) | 200-190 |
utilities | ClearFormatting (200) | 200 |
utility | InvisibleChars (100) | 100 |
Active state
Section titled “Active state”The toolbar tracks which buttons are active based on the cursor position. Active state is updated on every ProseMirror transaction.
String shorthand
Section titled “String shorthand”Check if a mark or node is active at the cursor:
isActive: 'bold' // editor.isActive('bold')Object with attributes
Section titled “Object with attributes”Check if a node is active with specific attributes:
isActive: { name: 'heading', attributes: { level: 2 } }// editor.isActive('heading', { level: 2 })Array (OR check)
Section titled “Array (OR check)”Active if any entry matches:
isActive: ['heading', { name: 'bulletList' }]// Active if heading OR bulletList is activeCustom function
Section titled “Custom function”For extensions that track state outside the node/mark system:
isActiveFn: (editor) => { const storage = editor.storage['myExtension'] as { isOpen: boolean }; return storage?.isOpen === true;}isActiveFn takes precedence over isActive when both are defined.
No active state
Section titled “No active state”Buttons like undo, redo, horizontal rule, and clear formatting have no meaningful active state. Omit isActive for these.
Disabled state
Section titled “Disabled state”The controller tracks disabled state by performing a dry-run of each button’s command using the can() system. If the command cannot execute in the current context, the button is disabled.
// Internally, the controller does:const canCmd = editor.can()[item.command];const isDisabled = !canCmd(...item.commandArgs);For dropdown triggers, the trigger is disabled when all of its sub-items are disabled.
For emitEvent buttons, the controller cannot dry-run the command (it needs user input like a URL). Instead, it disables the button when the cursor is inside a code block where marks and inserts are not applicable.
Dropdown variants
Section titled “Dropdown variants”Dropdowns support two layouts: 'list' (default) for vertical button lists, and 'grid' for color-swatch palettes. See Display modes for complete examples of each layout with code.
Dynamic trigger icon
Section titled “Dynamic trigger icon”The trigger icon updates to reflect the currently active sub-item. Used by Heading to show the active heading level icon:
{ type: 'dropdown', name: 'heading', icon: 'textH', // default icon dynamicIcon: true, // updates to active item's icon items: [/* heading1, heading2, ... */],}When cursor is in a Heading 2, the trigger shows the textHTwo icon instead of the generic textH.
Dynamic trigger label
Section titled “Dynamic trigger label”The trigger shows the active sub-item’s label as text instead of an icon. Used by FontFamily and FontSize:
// FontFamily: no fallback, reads inherited font-family from DOM{ type: 'dropdown', name: 'fontFamily', icon: 'textAa', dynamicLabel: true, computedStyleProperty: 'font-family', displayMode: 'text', items: [/* font items with style property */],}
// FontSize: uses fallback '16px' when no size is explicitly set{ type: 'dropdown', name: 'fontSize', icon: 'textSize', dynamicLabel: true, dynamicLabelFallback: '16px', computedStyleProperty: 'font-size', displayMode: 'text', items: [/* size items */],}When dynamicLabel is true:
- If a sub-item is active, the trigger shows that item’s label (e.g. “Georgia”)
- If no sub-item is active but
computedStylePropertyis set, the trigger reads the CSS computed style at the cursor position and shows that value (e.g. the inherited font-family) - If neither applies, the trigger shows
dynamicLabelFallbackor falls back to the icon
Display modes
Section titled “Display modes”The displayMode property controls how items appear inside the dropdown panel.
'icon-text' (default)
Section titled “'icon-text' (default)”Icon and label side by side. Used by Heading and TextAlign:
{ type: 'dropdown', name: 'heading', icon: 'textH', label: 'Heading', group: 'blocks', priority: 200, // displayMode: 'icon-text' is the default, no need to set it dynamicIcon: true, items: [ { type: 'button', name: 'paragraph', command: 'setParagraph', isActive: 'paragraph', icon: 'textT', label: 'Normal text' }, { type: 'button', name: 'heading1', command: 'toggleHeading', commandArgs: [{ level: 1 }], isActive: { name: 'heading', attributes: { level: 1 } }, icon: 'textHOne', label: 'Heading 1' }, { type: 'button', name: 'heading2', command: 'toggleHeading', commandArgs: [{ level: 2 }], isActive: { name: 'heading', attributes: { level: 2 } }, icon: 'textHTwo', label: 'Heading 2' }, ],}Each item shows its icon on the left and label on the right. The trigger uses dynamicIcon: true to update its icon to match the active sub-item (e.g. shows textHTwo when cursor is in Heading 2).
'text'
Section titled “'text'”Label only, no icons in the panel. Used by FontFamily, FontSize, and LineHeight:
{ type: 'dropdown', name: 'fontFamily', icon: 'textAa', label: 'Font Family', group: 'textStyle', priority: 150, displayMode: 'text', dynamicLabel: true, computedStyleProperty: 'font-family', items: [ { type: 'button', name: 'fontFamily-Arial', command: 'setFontFamily', commandArgs: ['Arial'], isActive: { name: 'textStyle', attributes: { fontFamily: 'Arial' } }, icon: 'textAa', label: 'Arial', style: 'font-family: Arial' }, { type: 'button', name: 'fontFamily-Georgia', command: 'setFontFamily', commandArgs: ['Georgia'], isActive: { name: 'textStyle', attributes: { fontFamily: 'Georgia' } }, icon: 'textAa', label: 'Georgia', style: 'font-family: Georgia' }, { type: 'button', name: 'fontFamily-Courier', command: 'setFontFamily', commandArgs: ['Courier New'], isActive: { name: 'textStyle', attributes: { fontFamily: 'Courier New' } }, icon: 'textAa', label: 'Courier New', style: "font-family: 'Courier New'" }, ],}Each item shows only its label text. The style property applies inline CSS so each font name is rendered in its own font as a preview. The trigger uses dynamicLabel: true to show the active font name instead of an icon, and computedStyleProperty: 'font-family' to read the inherited font from the DOM when no item is explicitly active.
'icon'
Section titled “'icon'”Icon only, no labels in the panel. Useful for compact toolbars or icon-based actions where the icons are self-explanatory:
{ type: 'dropdown', name: 'myAlignment', icon: 'textAlignLeft', label: 'Alignment', displayMode: 'icon', dynamicIcon: true, items: [ { type: 'button', name: 'myAlignLeft', command: 'setTextAlign', commandArgs: ['left'], isActive: 'paragraph', icon: 'textAlignLeft', label: 'Align left' }, { type: 'button', name: 'myAlignCenter', command: 'setTextAlign', commandArgs: ['center'], icon: 'textAlignCenter', label: 'Align center' }, { type: 'button', name: 'myAlignRight', command: 'setTextAlign', commandArgs: ['right'], icon: 'textAlignRight', label: 'Align right' }, ],}Each item shows only its icon. Labels are still used for aria-label and tooltips but are not rendered visually.
Grid layout with color swatches
Section titled “Grid layout with color swatches”The layout: 'grid' mode renders items in a CSS grid. Items with a color property render as color swatches. Items without color render as regular buttons:
{ type: 'dropdown', name: 'textColor', icon: 'textAUnderline', label: 'Text Color', group: 'textStyle', priority: 200, layout: 'grid', gridColumns: 5, defaultIndicatorColor: '#000000', items: [ // Regular button (no color) - renders as a full-width button at the top { type: 'button', name: 'unsetTextColor', command: 'unsetTextColor', icon: 'prohibit', label: 'Default' }, // Color swatches - render as small colored squares in the grid { type: 'button', name: 'textColor-red', command: 'setTextColor', commandArgs: ['#ef4444'], isActive: { name: 'textStyle', attributes: { color: '#ef4444' } }, icon: '', label: 'Red', color: '#ef4444' }, { type: 'button', name: 'textColor-orange', command: 'setTextColor', commandArgs: ['#f97316'], isActive: { name: 'textStyle', attributes: { color: '#f97316' } }, icon: '', label: 'Orange', color: '#f97316' }, { type: 'button', name: 'textColor-yellow', command: 'setTextColor', commandArgs: ['#eab308'], isActive: { name: 'textStyle', attributes: { color: '#eab308' } }, icon: '', label: 'Yellow', color: '#eab308' }, { type: 'button', name: 'textColor-green', command: 'setTextColor', commandArgs: ['#22c55e'], isActive: { name: 'textStyle', attributes: { color: '#22c55e' } }, icon: '', label: 'Green', color: '#22c55e' }, { type: 'button', name: 'textColor-blue', command: 'setTextColor', commandArgs: ['#3b82f6'], isActive: { name: 'textStyle', attributes: { color: '#3b82f6' } }, icon: '', label: 'Blue', color: '#3b82f6' }, ],}The trigger button shows a colored indicator bar at the bottom. When a color is active, the bar shows that color. When no color is active, it falls back to defaultIndicatorColor. The gridColumns property sets the number of columns (defaults to 10 if not specified).
emitEvent buttons
Section titled “emitEvent buttons”Some buttons need to open a UI panel (popover, dialog) that collects user input before executing a command. For example, the link button needs a URL before it can create a link. These buttons use emitEvent instead of executing a command directly.
{ type: 'button', name: 'link', command: 'unsetLink', // used for disabled state checking via can(), not executed on click emitEvent: 'linkEdit', // emitted on click instead of executing the command isActive: 'link', icon: 'link', label: 'Link', shortcut: 'Mod-K',}When clicked, the toolbar emits the event on the editor with { anchorElement } as payload. The anchorElement is the toolbar button element itself, which the popover uses for positioning.
The extension that handles the event opens a popover and stores its open/close state in editor.storage. The controller reads this storage to track the expanded state via expandedMap:
// The controller checks:const isOpen = editor.storage['link']?.isOpen === true;controller.expandedMap.get('link'); // true when popover is openBuilt-in extensions that use emitEvent: Link (linkEdit), Image (insertImage), and Emoji (insertEmoji).
Bubble-menu-only items
Section titled “Bubble-menu-only items”Some toolbar items should only appear in the bubble menu, not in the main toolbar. Set toolbar: false and provide a bubbleMenu context name:
{ type: 'button', name: 'imageFloatLeft', command: 'setImageFloat', commandArgs: ['left'], icon: 'textIndent', label: 'Float left', isActive: { name: 'image', attributes: { float: 'left' } }, toolbar: false, // exclude from main toolbar bubbleMenu: 'image', // include in the image bubble menu}The Image extension uses this pattern to register float controls (inline, left, center, right) and a delete button that only appear when an image is selected.
Custom layouts
Section titled “Custom layouts”By default, the toolbar renders items grouped by their group property. You can override this with a custom layout to reorder, filter, or regroup items.
A layout is an array of strings (item names or '|' separators) and custom dropdown objects:
import { ToolbarController } from '@domternal/core';import type { ToolbarLayoutEntry } from '@domternal/core';
const layout: ToolbarLayoutEntry[] = [ 'bold', 'italic', 'underline', '|', 'heading', '|', 'bulletList', 'orderedList', '|', 'link',];
const controller = new ToolbarController(editor, onChange, layout);controller.subscribe();import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalToolbarComponent } from '@domternal/angular';import { Editor, StarterKit, BubbleMenu } from '@domternal/core';import type { ToolbarLayoutEntry } from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent, DomternalToolbarComponent], template: ` @if (editor(); as ed) { <domternal-toolbar [editor]="ed" [layout]="layout" /> } <domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)" /> `,})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [StarterKit, BubbleMenu]; content = '<p>Hello world</p>';
layout: ToolbarLayoutEntry[] = [ 'bold', 'italic', 'underline', '|', 'heading', '|', 'bulletList', 'orderedList', '|', 'link', ];}import { Domternal } from '@domternal/react';import { StarterKit, BubbleMenu } from '@domternal/core';import type { ToolbarLayoutEntry } from '@domternal/core';
const layout: ToolbarLayoutEntry[] = [ 'bold', 'italic', 'underline', '|', 'heading', '|', 'bulletList', 'orderedList', '|', 'link',];
export default function Editor() { return ( <Domternal extensions={[StarterKit, BubbleMenu]} content="<p>Hello world</p>" > <Domternal.Toolbar layout={layout} /> <Domternal.Content /> </Domternal> );}Layout entries
Section titled “Layout entries”| Entry | Description |
|---|---|
'bold' | Include the item registered with name 'bold' |
'heading' | Include the dropdown registered with name 'heading' (with all its sub-items) |
'|' | Insert a visual separator |
{ dropdown: ... } | Create a custom dropdown (see below) |
Items not included in the layout are excluded from the toolbar. Only items registered by your extensions are available - referencing a name that does not exist is silently ignored.
Custom dropdown in layout
Section titled “Custom dropdown in layout”You can create ad-hoc dropdowns that group items differently from how extensions registered them:
const layout: ToolbarLayoutEntry[] = [ { dropdown: 'Format', icon: 'textB', items: ['bold', 'italic', 'underline', 'strike'], displayMode: 'icon-text', dynamicIcon: true, }, '|', { dropdown: 'Blocks', icon: 'textH', items: ['paragraph', 'heading1', 'heading2', 'heading3', 'bulletList', 'orderedList'], },];This creates two dropdowns even though the items were originally registered as standalone buttons and a separate heading dropdown. The layout system looks up items by name from all registered items (including sub-items of dropdowns).
| Property | Type | Required | Description |
|---|---|---|---|
dropdown | string | Yes | Label for the dropdown trigger |
icon | string | Yes | Icon key for the dropdown trigger |
items | string[] | Yes | Item names to include as sub-items |
displayMode | 'icon-text' | 'text' | 'icon' | No | How to render items in the panel |
dynamicIcon | boolean | No | Trigger icon updates to reflect the active sub-item |
ToolbarController API
Section titled “ToolbarController API”The ToolbarController class is the headless state machine that manages all toolbar state. Import it from @domternal/core.
Constructor
Section titled “Constructor”import { ToolbarController } from '@domternal/core';
const controller = new ToolbarController(editor, onChange, layout?);| Parameter | Type | Description |
|---|---|---|
editor | Editor | The editor instance |
onChange | () => void | Callback fired on every state change (re-render your UI here) |
layout | ToolbarLayoutEntry[] | Optional custom layout. If omitted, uses default group-based layout |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
groups | ToolbarGroup[] | Toolbar items grouped by group property or custom layout |
activeMap | ReadonlyMap<string, boolean> | Active state per button name |
disabledMap | ReadonlyMap<string, boolean> | Disabled state per button name |
expandedMap | ReadonlyMap<string, boolean> | Expanded state for emitEvent buttons (true when their panel is open) |
openDropdown | string | null | Name of the currently open dropdown, or null |
focusedIndex | number | Index of the focused button for keyboard navigation |
flatButtonCount | number | Total number of top-level buttons and dropdowns |
State methods
Section titled “State methods”| Method | Returns | Description |
|---|---|---|
isActive(item) | boolean | Check if a button is currently active |
isDisabled(item) | boolean | Check if a button’s command can execute |
executeCommand(item) | void | Execute a button’s command and update states |
toggleDropdown(name) | void | Open or close a dropdown by name |
closeDropdown() | void | Close any open dropdown |
Keyboard navigation methods
Section titled “Keyboard navigation methods”| Method | Returns | Description |
|---|---|---|
navigateNext() | number | Move focus to the next button (ArrowRight) |
navigatePrev() | number | Move focus to the previous button (ArrowLeft) |
navigateFirst() | number | Move focus to the first button (Home) |
navigateLast() | number | Move focus to the last button (End) |
setFocusedIndex(idx) | void | Set focus to a specific index (e.g. on mouse enter) |
getFlatIndex(name) | number | Get the flat index of an item by name (-1 if not found) |
Lifecycle methods
Section titled “Lifecycle methods”| Method | Description |
|---|---|
subscribe() | Start listening to editor transactions for active state updates |
destroy() | Stop listening and clean up |
Static helpers
Section titled “Static helpers”Shared utilities used by the toolbar and bubble menu:
// Check active state for a single buttonToolbarController.resolveActive(editor, item): boolean
// Execute a single button's commandToolbarController.executeItem(editor, item): voidAngular component
Section titled “Angular component”The <domternal-toolbar> component handles everything automatically.
Inputs
Section titled “Inputs”| Input | Type | Required | Default | Description |
|---|---|---|---|---|
editor | Editor | Yes | - | The editor instance |
icons | IconSet | null | No | null | Custom icon set. Falls back to defaultIcons |
layout | ToolbarLayoutEntry[] | No | undefined | Custom layout to reorder/filter items |
Features
Section titled “Features”- Auto-renders buttons, dropdowns, and separators from
editor.toolbarItems - Active state tracked via
ToolbarController.activeMapwitharia-pressed - Disabled state tracked via
ToolbarController.disabledMap - Dropdown positioning powered by
@floating-ui/domviapositionFloatingOnce - Keyboard navigation with ArrowLeft/Right, Home/End, Escape
- ARIA attributes:
role="toolbar",role="group",aria-pressed,aria-expanded, rovingtabindex - Icon caching as
SafeHtmlto prevent DOM re-sanitization - OnPush change detection with signals
- Click outside closes open dropdowns (listens for
dm:dismiss-overlaysfrom the editor)
Custom icon set
Section titled “Custom icon set”import type { IconSet } from '@domternal/core';
const myIcons: IconSet = { textB: '<svg><!-- custom bold icon --></svg>', textItalic: '<svg><!-- custom italic icon --></svg>',};<domternal-toolbar [editor]="ed" [icons]="myIcons" />When icons is provided, the component uses only that set. When icons is not provided (or null), it falls back to the built-in defaultIcons. If you want to override just a few icons, spread defaultIcons into your custom set:
import { defaultIcons } from '@domternal/core';
const myIcons = { ...defaultIcons, textB: '<svg><!-- custom bold --></svg>' };React component
Section titled “React component”The <DomternalToolbar /> component (or <Domternal.Toolbar /> via the composable pattern) handles everything automatically.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
editor | Editor | No | from context | The editor instance. If omitted, uses EditorProvider context |
icons | IconSet | No | undefined | Custom icon set. Falls back to defaultIcons |
layout | ToolbarLayoutEntry[] | No | undefined | Custom layout to reorder/filter items |
Features
Section titled “Features”- Auto-renders buttons, dropdowns, and separators from
editor.toolbarItems - Active state tracked via
ToolbarController.activeMapwitharia-pressed - Disabled state tracked via
ToolbarController.disabledMap - Dropdown positioning powered by
@floating-ui/domviapositionFloatingOnce - Keyboard navigation with ArrowLeft/Right, Home/End, Escape
- ARIA attributes:
role="toolbar",role="group",aria-pressed,aria-expanded, rovingtabindex - Icon caching for efficient re-renders
- Click outside closes open dropdowns (listens for
dm:dismiss-overlaysfrom the editor)
Custom icon set
Section titled “Custom icon set”import { Domternal } from '@domternal/react';import { defaultIcons } from '@domternal/core';import type { IconSet } from '@domternal/core';
const myIcons: IconSet = { ...defaultIcons, textB: '<svg><!-- custom bold icon --></svg>',};
export default function Editor() { return ( <Domternal extensions={[StarterKit]} content="<p>Hello</p>"> <Domternal.Toolbar icons={myIcons} /> <Domternal.Content /> </Domternal> );}When icons is provided, the component uses only that set. When icons is not provided, it falls back to the built-in defaultIcons.
Domternal ships 45 Phosphor icons as SVG strings in the defaultIcons export.
import { defaultIcons } from '@domternal/core';
// Use in vanilla JSbutton.innerHTML = defaultIcons.textB;Available icons
Section titled “Available icons”| Category | Icon keys |
|---|---|
| Format | textB, textItalic, textUnderline, textStrikethrough, code, highlighterCircle, textSubscript, textSuperscript, link, linkBreak |
| Blocks | paragraph, textH, textHOne, textHTwo, textHThree, textHFour, quotes, codeBlock, minus |
| Lists | list, listBullets, listNumbers, listChecks |
| Alignment | textAlignLeft, textAlignCenter, textAlignRight, textAlignJustify |
| Text style | textAUnderline, palette, textAa, textSize, lineSpacing |
| History | arrowCounterClockwise, arrowClockwise |
| Table | table, gridNine |
| Insert | image, smiley |
| Utility | textT, textTSlash, textIndent, prohibit, caretCircleRight, check, trash |
All icons are from Phosphor Icons (MIT license). Each icon is a <svg> string with viewBox="0 0 256 256" and fill="currentColor", so they inherit the button’s text color.
Custom icons
Section titled “Custom icons”Pass a custom IconSet to replace or extend the built-in icons. An IconSet is a Record<string, string> mapping icon keys to SVG strings:
import type { IconSet } from '@domternal/core';
const myIcons: IconSet = { // Override an existing icon textB: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="..."/></svg>',
// Add a new icon for a custom extension myCustomIcon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="..."/></svg>',};In vanilla JS, merge with defaultIcons:
import { defaultIcons } from '@domternal/core';
const allIcons = { ...defaultIcons, ...myIcons };In Angular, spread defaultIcons and pass via the icons input:
import { defaultIcons } from '@domternal/core';import type { IconSet } from '@domternal/core';
const myIcons: IconSet = { ...defaultIcons, ...customOverrides };<domternal-toolbar [editor]="ed" [icons]="myIcons" />Keyboard navigation
Section titled “Keyboard navigation”The toolbar implements the ARIA Toolbar pattern with roving tabindex:
| Key | Action |
|---|---|
ArrowRight | Move focus to the next button |
ArrowLeft | Move focus to the previous button |
Home | Move focus to the first button |
End | Move focus to the last button |
Enter / Space | Activate the focused button |
Escape | Close the open dropdown |
Only one button in the toolbar has tabindex="0" (the focused button). All others have tabindex="-1". This means pressing Tab moves focus out of the toolbar entirely, while arrow keys move between buttons within it.
Tooltip formatting
Section titled “Tooltip formatting”Tooltips display the button label and keyboard shortcut. Shortcuts are automatically formatted for the current platform:
| Shortcut in code | Mac tooltip | Windows tooltip |
|---|---|---|
Mod-B | Bold (⌘B) | Bold (Ctrl+B) |
Mod-Shift-K | Link (⌘⇧K) | Link (Ctrl+Shift+K) |
Mod-Alt-1 | Heading 1 (⌘⌥1) | Heading 1 (Ctrl+Alt+1) |
Mod maps to ⌘ on Mac and Ctrl on Windows. Shift maps to ⇧ on Mac. Alt maps to ⌥ on Mac. On Mac, keys are joined without separators (⌘B). On Windows, they are joined with + (Ctrl+B).
Focus preservation
Section titled “Focus preservation”Toolbar buttons use mousedown.preventDefault() to prevent the editor from losing focus when a button is clicked. Without this, clicking a toolbar button would blur the editor, causing the selection to disappear before the command executes.
If you build a custom toolbar in vanilla JS, add this to every button:
btn.addEventListener('mousedown', (e) => e.preventDefault());The Angular <domternal-toolbar> component handles this automatically.
Styling
Section titled “Styling”The toolbar uses CSS custom properties for theming. Override them on .dm-toolbar or any parent element.
Toolbar container
Section titled “Toolbar container”| Property | Default | Description |
|---|---|---|
--dm-toolbar-bg | var(--dm-bg, #ffffff) | Toolbar background |
--dm-toolbar-border | none | Toolbar border |
--dm-toolbar-padding | 0.375rem 0.5rem | Toolbar padding |
--dm-toolbar-gap | 0.125rem | Gap between items |
--dm-toolbar-border-radius | 0.75rem 0.75rem 0 0 | Toolbar corner radius |
Buttons
Section titled “Buttons”| Property | Default | Description |
|---|---|---|
--dm-button-size | 2rem | Button width and height |
--dm-button-border-radius | 0.375rem | Button corner radius |
--dm-button-color | var(--dm-text, #374151) | Icon/text color |
--dm-button-hover-bg | var(--dm-hover) | Hover background |
--dm-button-active-bg | var(--dm-accent-surface) | Active button background |
--dm-button-active-color | var(--dm-accent) | Active button icon color |
--dm-button-disabled-opacity | 0.35 | Disabled button opacity |
Separators
Section titled “Separators”| Property | Default | Description |
|---|---|---|
--dm-separator-color | var(--dm-border-color) | Separator color |
--dm-separator-margin | 0.375rem | Separator vertical margin |
CSS classes
Section titled “CSS classes”| Class | Description |
|---|---|
.dm-toolbar | Toolbar container |
.dm-toolbar-group | Group of related buttons |
.dm-toolbar-button | Individual button |
.dm-toolbar-button--active | Active button state |
.dm-toolbar-separator | Vertical separator between groups |
.dm-toolbar-dropdown-trigger | Dropdown trigger button |
.dm-toolbar-dropdown-wrapper | Dropdown container (trigger + panel) |
.dm-toolbar-dropdown-panel | Dropdown panel |
.dm-toolbar-dropdown-item | Item inside a dropdown panel |
.dm-color-palette | Grid layout panel |
.dm-color-swatch | Color swatch button in grid |
Example
Section titled “Example”.my-editor .dm-toolbar { --dm-toolbar-bg: #f8fafc; --dm-toolbar-padding: 0.5rem; --dm-toolbar-border: 1px solid #e2e8f0; --dm-toolbar-border-radius: 0; --dm-button-size: 2.25rem; --dm-button-border-radius: 0.5rem; --dm-button-active-bg: #dbeafe; --dm-button-active-color: #2563eb;}Dark mode
Section titled “Dark mode”The toolbar automatically adapts to dark mode when the @domternal/theme package is loaded. Add .dm-theme-dark to the editor container for a forced dark theme, or .dm-theme-auto to follow the system preference:
<!-- Always dark --><div class="dm-theme-dark"> <div class="dm-toolbar">...</div> <div id="editor" class="dm-editor">...</div></div>
<!-- Follow system preference --><div class="dm-theme-auto"> <div class="dm-toolbar">...</div> <div id="editor" class="dm-editor">...</div></div>All toolbar CSS custom properties are redefined in dark mode. No additional configuration is needed.
Wrapping
Section titled “Wrapping”The toolbar uses flex-wrap: wrap, so buttons automatically wrap to the next line when there is not enough horizontal space. This makes the toolbar responsive on narrow screens without any extra configuration.
For the complete list of CSS custom properties (including editor content, bubble menu, and dark mode), see Theming.
Writing a custom extension with toolbar items
Section titled “Writing a custom extension with toolbar items”Here is a complete example of a custom extension that registers a grid dropdown with color swatches:
import { Extension } from '@domternal/core';import type { ToolbarItem, ToolbarButton } from '@domternal/core';
export const CustomHighlight = Extension.create({ name: 'customHighlight',
addOptions() { return { colors: ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'], }; },
addToolbarItems(): ToolbarItem[] { const colorItems: ToolbarButton[] = this.options.colors.map((color, i) => ({ type: 'button' as const, name: `highlight-${color}`, command: 'setHighlight', commandArgs: [{ color }], isActive: { name: 'textStyle', attributes: { backgroundColor: color } }, icon: '', label: color, color, priority: 200 - i, }));
return [ { type: 'dropdown', name: 'customHighlight', icon: 'highlighterCircle', label: 'Highlight', group: 'textStyle', priority: 190, layout: 'grid', gridColumns: 5, defaultIndicatorColor: '#fef08a', items: [ { type: 'button', name: 'removeHighlight', command: 'unsetHighlight', icon: 'prohibit', label: 'Remove', }, ...colorItems, ], }, ]; },});All extensions with toolbar items
Section titled “All extensions with toolbar items”These extensions register toolbar items via addToolbarItems(). Each item is automatically included in the toolbar when the extension is added to the editor.
| Extension | Item type | Name | Group |
|---|---|---|---|
| Bold | Button | bold | format |
| Italic | Button | italic | format |
| Underline | Button | underline | format |
| Strike | Button | strike | format |
| Code | Button | code | format |
| Highlight | Button or Dropdown (grid) | highlight | format |
| Subscript | Button | subscript | format |
| Superscript | Button | superscript | format |
| Link | Button | link | format |
| Heading | Dropdown | heading | blocks |
| Blockquote | Button | blockquote | blocks |
| Code Block | Button | codeBlock | blocks |
| Horizontal Rule | Button | horizontalRule | blocks |
| Bullet List | Button | bulletList | lists |
| Ordered List | Button | orderedList | lists |
| Task List | Button | taskList | lists |
| Text Color | Dropdown (grid) | textColor | textStyle |
| Font Family | Dropdown | fontFamily | textStyle |
| Font Size | Dropdown | fontSize | textStyle |
| Line Height | Dropdown | lineHeight | textStyle |
| Text Align | Dropdown | textAlign | alignment |
| Image | Button | image | insert |
| Table | Button | table | insert |
| Emoji | Button | emoji | insert |
| Hard Break | Button | hardBreak | insert |
| History | Buttons | undo, redo | history |
| Clear Formatting | Button | clearFormatting | utilities |
| Invisible Characters | Button | invisibleChars | utility |