Bubble Menu
BubbleMenu shows a floating menu when the user selects text in the editor. It is commonly used to display contextual formatting buttons (bold, italic, link, etc.) that appear near the selection. The menu is positioned using @floating-ui/dom and supports custom shouldShow logic, placement, and offset.
Not included in StarterKit. Add it separately when you need a contextual selection menu.
Live Playground
Section titled “Live Playground”Select text in the editor to see the bubble menu appear above your selection with formatting buttons. Click a button to apply formatting to the selected text.
With the default theme. The bubble menu shows bold, italic, underline, strike, and code buttons with active state highlighting.
Vanilla JS preview - Angular <domternal-bubble-menu> component produces the same output
Vanilla JS preview - React components produce the same output
BubbleMenu needs an HTML element to render the menu into. Create a <div> with the dm-bubble-menu class, pass it to the extension, then populate it with formatting buttons using ToolbarController.executeItem() and defaultIcons.
import { Editor, Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu, ToolbarController, defaultIcons,} from '@domternal/core';import '@domternal/theme';
// 1. Create the bubble menu containerconst menuEl = document.createElement('div');menuEl.className = 'dm-bubble-menu';
// 2. Create the editor with BubbleMenu extensionconst editor = new Editor({ element: document.getElementById('editor')!, extensions: [ Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu.configure({ element: menuEl }), ], content: '<p>Select some text to see the bubble menu appear above the selection.</p>',});
// 3. Populate with formatting buttons from registered toolbar itemsconst itemNames = ['bold', 'italic', 'underline', 'strike', 'code', 'link'];const items = editor.toolbarItems.filter( i => i.type === 'button' && itemNames.includes(i.name));
for (const item of items) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dm-toolbar-button'; btn.title = item.label; btn.innerHTML = defaultIcons[item.icon] ?? ''; btn.addEventListener('mousedown', e => e.preventDefault()); btn.addEventListener('click', () => { ToolbarController.executeItem(editor, item); }); menuEl.appendChild(btn);}The mousedown preventDefault() on each button is important - it prevents the editor from losing focus when clicking a bubble menu button.
You can also add separators between button groups:
const sep = document.createElement('span');sep.className = 'dm-toolbar-separator';menuEl.insertBefore(sep, menuEl.children[3]); // after 3rd buttonThe Angular <domternal-bubble-menu> component handles everything automatically. It discovers formatting items from the editor’s extensions, renders buttons with active/disabled states, and manages positioning. Just add BubbleMenu to the extensions array and place the component in your template.
import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalToolbarComponent, DomternalBubbleMenuComponent,} from '@domternal/angular';import { Editor, Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu,} from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent, DomternalToolbarComponent, DomternalBubbleMenuComponent], templateUrl: './editor.html',})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu]; content = '<p>Select some text to see the bubble menu appear.</p>';}@if (editor(); as ed) { <domternal-toolbar [editor]="ed" />}<domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)"/>@if (editor(); as ed) { <domternal-bubble-menu [editor]="ed" />}To control which buttons appear, pass items:
<domternal-bubble-menu [editor]="editor()!" [items]="['bold', 'italic', 'underline', '|', 'link']"/>Use '|' to insert a separator between buttons.
import { Domternal } from '@domternal/react';import { Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu,} from '@domternal/core';
export default function Editor() { return ( <Domternal extensions={[Document, Paragraph, Text, Bold, Italic, Underline, Strike, Code, Link, BubbleMenu]} content="<p>Select some text to see the bubble menu appear.</p>" > <Domternal.Toolbar /> <Domternal.Content /> <Domternal.BubbleMenu /> </Domternal> );}Without @domternal/theme, you provide your own styling. Create any HTML element, pass it as the element option, and wire up click handlers manually.
import { Editor, Document, Paragraph, Text, Bold, Italic, Underline, BubbleMenu } from '@domternal/core';
// 1. Create a custom menu element with your own stylesconst menuEl = document.createElement('div');menuEl.style.cssText = ` position: absolute; display: none; background: white; border: 1px solid #ccc; padding: 4px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);`;menuEl.innerHTML = ` <button data-cmd="toggleBold"><b>B</b></button> <button data-cmd="toggleItalic"><i>I</i></button> <button data-cmd="toggleUnderline"><u>U</u></button>`;
// 2. Create the editorconst editor = new Editor({ element: document.getElementById('editor')!, extensions: [ Document, Paragraph, Text, Bold, Italic, Underline, BubbleMenu.configure({ element: menuEl }), ], content: '<p>Select some text to see the bubble menu.</p>',});
// 3. Wire up command buttonsmenuEl.addEventListener('click', (e) => { const btn = (e.target as Element).closest<HTMLButtonElement>('[data-cmd]'); if (!btn) return; const cmd = btn.dataset.cmd!; (editor.chain().focus() as any)[cmd]().run();});
// 4. Show/hide based on data-show attribute (set by the plugin)const observer = new MutationObserver(() => { menuEl.style.display = menuEl.hasAttribute('data-show') ? 'flex' : 'none';});observer.observe(menuEl, { attributes: true, attributeFilter: ['data-show'] });Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
element | HTMLElement | null | null | The HTML element that serves as the menu container. Required - if null, the plugin is not created. |
updateDelay | number | 0 | Delay in milliseconds before showing the menu after a selection change |
shouldShow | (props: { editor, view, state, from, to }) => boolean | See below | Custom function to control when the menu is visible |
placement | 'top' | 'bottom' | 'top' | Position of the menu relative to the selection |
offset | number | 8 | Distance in pixels between the selection and the menu |
BubbleMenu.configure({ element: menuEl, placement: 'bottom', offset: 12, updateDelay: 100, shouldShow: ({ editor, view, state, from, to }) => { return !state.selection.empty && editor.isEditable; },})Default shouldShow behavior
Section titled “Default shouldShow behavior”The built-in shouldShow function shows the menu when all of these are true:
- The selection is not empty (has a range)
- The selection is a TextSelection (not a NodeSelection for images, HRs, etc.)
- The selected range contains actual text (not just an empty paragraph double-click)
- The editor is editable (
editor.isEditable) - The cursor is not inside a table cell or header (table cells use their own toolbar)
Override shouldShow to customize this logic. For example, to also show the menu for node selections:
BubbleMenu.configure({ element: menuEl, shouldShow: ({ editor, view, state, from, to }) => { if (state.selection.empty || !editor.isEditable) return false; return true; },})Commands
Section titled “Commands”BubbleMenu does not register any commands.
Keyboard shortcuts
Section titled “Keyboard shortcuts”BubbleMenu does not register any keyboard shortcuts.
Input rules
Section titled “Input rules”BubbleMenu does not register any input rules.
Toolbar items
Section titled “Toolbar items”BubbleMenu does not register any toolbar items.
How it works
Section titled “How it works”Positioning
Section titled “Positioning”The menu element is reparented inside .dm-editor (which has position: relative) on plugin creation. This allows the menu to use position: absolute with CSS compositor-driven scroll handling, avoiding jitter during scroll.
Positioning is handled by positionFloatingOnce() (which wraps @floating-ui/dom). For text selections, it builds a virtual reference rectangle from coordsAtPos(from) and coordsAtPos(to). For node selections (images, HRs), it uses the actual DOM element for precise alignment.
Visibility
Section titled “Visibility”The menu uses visibility: hidden / visible and opacity: 0 / 1 controlled by the data-show attribute. When visible, data-show is set; when hidden, it is removed.
Mouse drag suppression
Section titled “Mouse drag suppression”The menu is hidden during active mouse drags inside the editor. Without this, the menu element could block ProseMirror’s posAtCoords() resolution (particularly for table cell selection conversion). The menu reappears after mouseup once the selection has settled.
Outside click handling
Section titled “Outside click handling”Clicking outside both the editor and the bubble menu hides the menu and suppresses it until the selection changes. This prevents the menu from reappearing when clicking toolbar buttons that modify the selection.
Clicking inside the menu itself calls preventDefault() on mousedown to prevent the editor from losing focus.
Focus and blur
Section titled “Focus and blur”- Focus - when the editor regains focus, the menu re-evaluates
shouldShowwith the current state and repositions if needed. - Blur - when the editor loses focus, the menu hides, unless focus moved to the bubble menu element itself (e.g., clicking a button inside the menu).
Overlay dismissal
Section titled “Overlay dismissal”The menu listens for the custom dm:dismiss-overlays event on the .dm-editor element. When other overlays open (e.g., table dropdown menus), they emit this event to close the bubble menu.
IME composition
Section titled “IME composition”The menu skips updates during active IME composition (view.composing) to avoid repositioning while the user is composing characters.
Plugin state
Section titled “Plugin state”The plugin maintains state with three properties:
visible- whether the menu should be shownfrom- selection start positionto- selection end position
State is recalculated on every transaction. Suppression is reset when the selection range changes.
Angular component
Section titled “Angular component”The <domternal-bubble-menu> component provides a full-featured bubble menu for Angular applications.
Inputs
Section titled “Inputs”| Input | Type | Default | Description |
|---|---|---|---|
editor | Editor | Required | The editor instance |
shouldShow | function | Auto | Custom visibility function |
placement | 'top' | 'bottom' | 'top' | Menu placement |
offset | number | 8 | Offset from selection |
updateDelay | number | 0 | Show delay in ms |
items | string[] | Auto | Fixed list of item names to show (e.g. ['bold', 'italic', 'code']) |
contexts | Record<string, string[] | true | null> | Auto | Context-aware item configuration (e.g. { image: true }) |
Auto mode vs fixed items
Section titled “Auto mode vs fixed items”By default (no items or contexts input), the component auto-discovers formatting items from the editor’s registered extensions and renders them as buttons.
With items, you specify exactly which buttons to show:
<domternal-bubble-menu [editor]="editor()!" [items]="['bold', 'italic', 'underline', 'strike', 'code', 'link']"/>Context-aware mode
Section titled “Context-aware mode”With contexts, the component shows different items depending on the selection context (e.g., different buttons when an image is selected vs. text):
<domternal-bubble-menu [editor]="editor()!" [contexts]="{ image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage'] }"/>Set a context to true to show all items registered with that bubbleMenu value, or pass an array to specify exact items. Use '|' for separators.
Standalone plugin
Section titled “Standalone plugin”For framework wrappers or advanced use cases, you can create the ProseMirror plugin directly without the extension system:
import { createBubbleMenuPlugin, PluginKey } from '@domternal/core';
const plugin = createBubbleMenuPlugin({ pluginKey: new PluginKey('myBubbleMenu'), editor, element: menuEl, shouldShow: ({ editor, view, state, from, to }) => !state.selection.empty, placement: 'top', offset: 8,});This is what the Angular <domternal-bubble-menu> component uses internally.
Styling
Section titled “Styling”The @domternal/theme package includes styles for .dm-bubble-menu.
| Class | Description |
|---|---|
.dm-bubble-menu | Main container (absolute position, z-index 50, flex layout) |
.dm-bubble-menu[data-show] | Visible state (opacity 1, visibility visible) |
.dm-toolbar-button | Reuses toolbar button styles (compact 1.75rem size in bubble menu) |
.dm-toolbar-separator | Vertical separator (1px wide, 1.125rem tall) |
The bubble menu uses compact sizing: smaller buttons (1.75rem), smaller icons (1rem), and reduced padding (0.25rem) compared to the main toolbar.
Exports
Section titled “Exports”import { BubbleMenu, createBubbleMenuPlugin, bubbleMenuPluginKey } from '@domternal/core';import type { BubbleMenuOptions, CreateBubbleMenuPluginOptions } from '@domternal/core';| Export | Type | Description |
|---|---|---|
BubbleMenu | Extension | The bubble menu extension |
createBubbleMenuPlugin | Function | Create a standalone ProseMirror bubble menu plugin |
bubbleMenuPluginKey | PluginKey | The default plugin key |
BubbleMenuOptions | TypeScript type | Options for BubbleMenu.configure() |
CreateBubbleMenuPluginOptions | TypeScript type | Options for createBubbleMenuPlugin() |
Source
Section titled “Source”@domternal/core - BubbleMenu.ts