Skip to content

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.

  1. Each extension registers toolbar items via addToolbarItems() during initialization
  2. The editor collects all items into editor.toolbarItems
  3. ToolbarController groups, sorts, and tracks the state of every item
  4. On every editor transaction, the controller updates active and disabled states
  5. A framework component (or your own UI) reads the controller state and renders buttons
Extensions → addToolbarItems() → editor.toolbarItems → ToolbarController → UI
import {
Editor, StarterKit, ToolbarController, defaultIcons,
} from '@domternal/core';
import '@domternal/theme';
const editorEl = document.getElementById('editor')!;
// Create toolbar element
const toolbar = document.createElement('div');
toolbar.className = 'dm-toolbar';
toolbar.setAttribute('role', 'toolbar');
editorEl.before(toolbar);
// Create editor
const editor = new Editor({
element: editorEl,
extensions: [StarterKit],
content: '<p>Hello world</p>',
});
// Create controller
const 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.

Extensions register three types of toolbar items: buttons, dropdowns, and separators.

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,
}
PropertyTypeDefaultDescription
type'button'-Item type identifier
namestring-Unique identifier for this item
commandstring-Command name to execute (key of editor.commands)
commandArgsunknown[]undefinedArguments passed to the command
iconstring-Icon key resolved from the icon set
labelstring-Tooltip text and aria-label
shortcutstringundefinedKeyboard shortcut shown in the tooltip
groupstring''Group name for visual grouping (separators between groups)
prioritynumber100Sort order within group (higher values appear first)
isActivestring | object | arrayundefinedHow to check if this button is active (see Active state)
isActiveFn(editor) => booleanundefinedCustom function for active state (takes precedence over isActive)
stylestringundefinedInline CSS applied to the button (e.g. 'font-family: Georgia' for font preview)
emitEventstringundefinedEvent name to emit instead of executing the command (see emitEvent buttons)
colorstringundefinedHex color for color-swatch rendering in grid dropdowns
toolbarbooleantrueShow in main toolbar. Set false for bubble-menu-only items
bubbleMenustringundefinedContext name for auto-including in the bubble menu

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' },
],
}
PropertyTypeDefaultDescription
type'dropdown'-Item type identifier
namestring-Unique identifier
iconstring-Icon key for the trigger button
labelstring-Tooltip text and aria-label
itemsToolbarButton[]-Buttons shown in the dropdown panel
groupstring''Group name for visual grouping
prioritynumber100Sort order within group
layout'list' | 'grid''list'Panel layout: vertical list or color-swatch grid
gridColumnsnumber10Number of columns in grid layout
displayMode'icon-text' | 'text' | 'icon''icon-text'How to render items in the panel
dynamicIconbooleanundefinedTrigger icon updates to reflect the active sub-item’s icon
dynamicLabelbooleanundefinedTrigger shows the active sub-item’s label as text instead of an icon
dynamicLabelFallbackstringundefinedText shown when dynamicLabel is true and no item is active
computedStylePropertystringundefinedCSS property to read from cursor position when no item is active (e.g. 'font-size', 'font-family')
defaultIndicatorColorstringundefinedFallback hex color for the trigger indicator bar (grid dropdowns only)

A visual divider between toolbar groups. Separators are inserted automatically between groups - you rarely need to register them manually.

{ type: 'separator' }
PropertyTypeDefaultDescription
type'separator'-Item type identifier
namestringundefinedOptional unique identifier
groupstringundefinedGroup name (for ordering)
prioritynumber100Sort order within group

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.

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.

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).

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.

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:

GroupExtensionsPriority range
formatBold (200), Italic (190), Underline (180), Strike (170), Code (160), Highlight (150), Subscript (140), Superscript (130), Link (120)200-120
blocksHeading dropdown (200), Blockquote (150), CodeBlock (140), HorizontalRule (130)200-130
listsBulletList (200), OrderedList (190), TaskList (170)200-170
textStyleTextColor (200), FontFamily (150), FontSize (100), LineHeight (50)200-50
alignmentTextAlign dropdown (200)200
insertImage (150), Table (140), Emoji (50), HardBreak (50)150-50
historyUndo (200), Redo (190)200-190
utilitiesClearFormatting (200)200
utilityInvisibleChars (100)100

The toolbar tracks which buttons are active based on the cursor position. Active state is updated on every ProseMirror transaction.

Check if a mark or node is active at the cursor:

isActive: 'bold' // editor.isActive('bold')

Check if a node is active with specific attributes:

isActive: { name: 'heading', attributes: { level: 2 } }
// editor.isActive('heading', { level: 2 })

Active if any entry matches:

isActive: ['heading', { name: 'bulletList' }]
// Active if heading OR bulletList is active

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.

Buttons like undo, redo, horizontal rule, and clear formatting have no meaningful active state. Omit isActive for these.

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.

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.

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.

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:

  1. If a sub-item is active, the trigger shows that item’s label (e.g. “Georgia”)
  2. If no sub-item is active but computedStyleProperty is set, the trigger reads the CSS computed style at the cursor position and shows that value (e.g. the inherited font-family)
  3. If neither applies, the trigger shows dynamicLabelFallback or falls back to the icon

The displayMode property controls how items appear inside the dropdown panel.

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).

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 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.

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).

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 open

Built-in extensions that use emitEvent: Link (linkEdit), Image (insertImage), and Emoji (insertEmoji).

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.

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();
EntryDescription
'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.

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).

PropertyTypeRequiredDescription
dropdownstringYesLabel for the dropdown trigger
iconstringYesIcon key for the dropdown trigger
itemsstring[]YesItem names to include as sub-items
displayMode'icon-text' | 'text' | 'icon'NoHow to render items in the panel
dynamicIconbooleanNoTrigger icon updates to reflect the active sub-item

The ToolbarController class is the headless state machine that manages all toolbar state. Import it from @domternal/core.

import { ToolbarController } from '@domternal/core';
const controller = new ToolbarController(editor, onChange, layout?);
ParameterTypeDescription
editorEditorThe editor instance
onChange() => voidCallback fired on every state change (re-render your UI here)
layoutToolbarLayoutEntry[]Optional custom layout. If omitted, uses default group-based layout
PropertyTypeDescription
groupsToolbarGroup[]Toolbar items grouped by group property or custom layout
activeMapReadonlyMap<string, boolean>Active state per button name
disabledMapReadonlyMap<string, boolean>Disabled state per button name
expandedMapReadonlyMap<string, boolean>Expanded state for emitEvent buttons (true when their panel is open)
openDropdownstring | nullName of the currently open dropdown, or null
focusedIndexnumberIndex of the focused button for keyboard navigation
flatButtonCountnumberTotal number of top-level buttons and dropdowns
MethodReturnsDescription
isActive(item)booleanCheck if a button is currently active
isDisabled(item)booleanCheck if a button’s command can execute
executeCommand(item)voidExecute a button’s command and update states
toggleDropdown(name)voidOpen or close a dropdown by name
closeDropdown()voidClose any open dropdown
MethodReturnsDescription
navigateNext()numberMove focus to the next button (ArrowRight)
navigatePrev()numberMove focus to the previous button (ArrowLeft)
navigateFirst()numberMove focus to the first button (Home)
navigateLast()numberMove focus to the last button (End)
setFocusedIndex(idx)voidSet focus to a specific index (e.g. on mouse enter)
getFlatIndex(name)numberGet the flat index of an item by name (-1 if not found)
MethodDescription
subscribe()Start listening to editor transactions for active state updates
destroy()Stop listening and clean up

Shared utilities used by the toolbar and bubble menu:

// Check active state for a single button
ToolbarController.resolveActive(editor, item): boolean
// Execute a single button's command
ToolbarController.executeItem(editor, item): void

The <domternal-toolbar> component handles everything automatically.

InputTypeRequiredDefaultDescription
editorEditorYes-The editor instance
iconsIconSet | nullNonullCustom icon set. Falls back to defaultIcons
layoutToolbarLayoutEntry[]NoundefinedCustom layout to reorder/filter items
  • Auto-renders buttons, dropdowns, and separators from editor.toolbarItems
  • Active state tracked via ToolbarController.activeMap with aria-pressed
  • Disabled state tracked via ToolbarController.disabledMap
  • Dropdown positioning powered by @floating-ui/dom via positionFloatingOnce
  • Keyboard navigation with ArrowLeft/Right, Home/End, Escape
  • ARIA attributes: role="toolbar", role="group", aria-pressed, aria-expanded, roving tabindex
  • Icon caching as SafeHtml to prevent DOM re-sanitization
  • OnPush change detection with signals
  • Click outside closes open dropdowns (listens for dm:dismiss-overlays from the editor)
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>' };

The <DomternalToolbar /> component (or <Domternal.Toolbar /> via the composable pattern) handles everything automatically.

PropTypeRequiredDefaultDescription
editorEditorNofrom contextThe editor instance. If omitted, uses EditorProvider context
iconsIconSetNoundefinedCustom icon set. Falls back to defaultIcons
layoutToolbarLayoutEntry[]NoundefinedCustom layout to reorder/filter items
  • Auto-renders buttons, dropdowns, and separators from editor.toolbarItems
  • Active state tracked via ToolbarController.activeMap with aria-pressed
  • Disabled state tracked via ToolbarController.disabledMap
  • Dropdown positioning powered by @floating-ui/dom via positionFloatingOnce
  • Keyboard navigation with ArrowLeft/Right, Home/End, Escape
  • ARIA attributes: role="toolbar", role="group", aria-pressed, aria-expanded, roving tabindex
  • Icon caching for efficient re-renders
  • Click outside closes open dropdowns (listens for dm:dismiss-overlays from the editor)
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 JS
button.innerHTML = defaultIcons.textB;
CategoryIcon keys
FormattextB, textItalic, textUnderline, textStrikethrough, code, highlighterCircle, textSubscript, textSuperscript, link, linkBreak
Blocksparagraph, textH, textHOne, textHTwo, textHThree, textHFour, quotes, codeBlock, minus
Listslist, listBullets, listNumbers, listChecks
AlignmenttextAlignLeft, textAlignCenter, textAlignRight, textAlignJustify
Text styletextAUnderline, palette, textAa, textSize, lineSpacing
HistoryarrowCounterClockwise, arrowClockwise
Tabletable, gridNine
Insertimage, smiley
UtilitytextT, 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.

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" />

The toolbar implements the ARIA Toolbar pattern with roving tabindex:

KeyAction
ArrowRightMove focus to the next button
ArrowLeftMove focus to the previous button
HomeMove focus to the first button
EndMove focus to the last button
Enter / SpaceActivate the focused button
EscapeClose 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.

Tooltips display the button label and keyboard shortcut. Shortcuts are automatically formatted for the current platform:

Shortcut in codeMac tooltipWindows tooltip
Mod-BBold (⌘B)Bold (Ctrl+B)
Mod-Shift-KLink (⌘⇧K)Link (Ctrl+Shift+K)
Mod-Alt-1Heading 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).

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.

The toolbar uses CSS custom properties for theming. Override them on .dm-toolbar or any parent element.

PropertyDefaultDescription
--dm-toolbar-bgvar(--dm-bg, #ffffff)Toolbar background
--dm-toolbar-bordernoneToolbar border
--dm-toolbar-padding0.375rem 0.5remToolbar padding
--dm-toolbar-gap0.125remGap between items
--dm-toolbar-border-radius0.75rem 0.75rem 0 0Toolbar corner radius
PropertyDefaultDescription
--dm-button-size2remButton width and height
--dm-button-border-radius0.375remButton corner radius
--dm-button-colorvar(--dm-text, #374151)Icon/text color
--dm-button-hover-bgvar(--dm-hover)Hover background
--dm-button-active-bgvar(--dm-accent-surface)Active button background
--dm-button-active-colorvar(--dm-accent)Active button icon color
--dm-button-disabled-opacity0.35Disabled button opacity
PropertyDefaultDescription
--dm-separator-colorvar(--dm-border-color)Separator color
--dm-separator-margin0.375remSeparator vertical margin
ClassDescription
.dm-toolbarToolbar container
.dm-toolbar-groupGroup of related buttons
.dm-toolbar-buttonIndividual button
.dm-toolbar-button--activeActive button state
.dm-toolbar-separatorVertical separator between groups
.dm-toolbar-dropdown-triggerDropdown trigger button
.dm-toolbar-dropdown-wrapperDropdown container (trigger + panel)
.dm-toolbar-dropdown-panelDropdown panel
.dm-toolbar-dropdown-itemItem inside a dropdown panel
.dm-color-paletteGrid layout panel
.dm-color-swatchColor swatch button in grid
.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;
}

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.

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,
],
},
];
},
});

These extensions register toolbar items via addToolbarItems(). Each item is automatically included in the toolbar when the extension is added to the editor.

ExtensionItem typeNameGroup
BoldButtonboldformat
ItalicButtonitalicformat
UnderlineButtonunderlineformat
StrikeButtonstrikeformat
CodeButtoncodeformat
HighlightButton or Dropdown (grid)highlightformat
SubscriptButtonsubscriptformat
SuperscriptButtonsuperscriptformat
LinkButtonlinkformat
HeadingDropdownheadingblocks
BlockquoteButtonblockquoteblocks
Code BlockButtoncodeBlockblocks
Horizontal RuleButtonhorizontalRuleblocks
Bullet ListButtonbulletListlists
Ordered ListButtonorderedListlists
Task ListButtontaskListlists
Text ColorDropdown (grid)textColortextStyle
Font FamilyDropdownfontFamilytextStyle
Font SizeDropdownfontSizetextStyle
Line HeightDropdownlineHeighttextStyle
Text AlignDropdowntextAlignalignment
ImageButtonimageinsert
TableButtontableinsert
EmojiButtonemojiinsert
Hard BreakButtonhardBreakinsert
HistoryButtonsundo, redohistory
Clear FormattingButtonclearFormattingutilities
Invisible CharactersButtoninvisibleCharsutility