Skip to content

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.

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.

Click to try it out

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 container
const menuEl = document.createElement('div');
menuEl.className = 'dm-bubble-menu';
// 2. Create the editor with BubbleMenu extension
const 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 items
const 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 button
OptionTypeDefaultDescription
elementHTMLElement | nullnullThe HTML element that serves as the menu container. Required - if null, the plugin is not created.
updateDelaynumber0Delay in milliseconds before showing the menu after a selection change
shouldShow(props: { editor, view, state, from, to }) => booleanSee belowCustom function to control when the menu is visible
placement'top' | 'bottom''top'Position of the menu relative to the selection
offsetnumber8Distance 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;
},
})

The built-in shouldShow function shows the menu when all of these are true:

  1. The selection is not empty (has a range)
  2. The selection is a TextSelection (not a NodeSelection for images, HRs, etc.)
  3. The selected range contains actual text (not just an empty paragraph double-click)
  4. The editor is editable (editor.isEditable)
  5. 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;
},
})

BubbleMenu does not register any commands.

BubbleMenu does not register any keyboard shortcuts.

BubbleMenu does not register any input rules.

BubbleMenu does not register any toolbar items.

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.

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.

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.

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 - when the editor regains focus, the menu re-evaluates shouldShow with 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).

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.

The menu skips updates during active IME composition (view.composing) to avoid repositioning while the user is composing characters.

The plugin maintains state with three properties:

  • visible - whether the menu should be shown
  • from - selection start position
  • to - selection end position

State is recalculated on every transaction. Suppression is reset when the selection range changes.

The <domternal-bubble-menu> component provides a full-featured bubble menu for Angular applications.

InputTypeDefaultDescription
editorEditorRequiredThe editor instance
shouldShowfunctionAutoCustom visibility function
placement'top' | 'bottom''top'Menu placement
offsetnumber8Offset from selection
updateDelaynumber0Show delay in ms
itemsstring[]AutoFixed list of item names to show (e.g. ['bold', 'italic', 'code'])
contextsRecord<string, string[] | true | null>AutoContext-aware item configuration (e.g. { image: true })

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

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.

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.

The @domternal/theme package includes styles for .dm-bubble-menu.

ClassDescription
.dm-bubble-menuMain container (absolute position, z-index 50, flex layout)
.dm-bubble-menu[data-show]Visible state (opacity 1, visibility visible)
.dm-toolbar-buttonReuses toolbar button styles (compact 1.75rem size in bubble menu)
.dm-toolbar-separatorVertical 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.

import { BubbleMenu, createBubbleMenuPlugin, bubbleMenuPluginKey } from '@domternal/core';
import type { BubbleMenuOptions, CreateBubbleMenuPluginOptions } from '@domternal/core';
ExportTypeDescription
BubbleMenuExtensionThe bubble menu extension
createBubbleMenuPluginFunctionCreate a standalone ProseMirror bubble menu plugin
bubbleMenuPluginKeyPluginKeyThe default plugin key
BubbleMenuOptionsTypeScript typeOptions for BubbleMenu.configure()
CreateBubbleMenuPluginOptionsTypeScript typeOptions for createBubbleMenuPlugin()

@domternal/core - BubbleMenu.ts