Skip to content

Floating Menu

FloatingMenu shows a floating block-insert menu when the cursor sits at the start of an empty paragraph. Extensions contribute items via the addFloatingMenuItems() hook, items are grouped and ranked via FloatingMenuController, and keyboard navigation uses a roving-tabindex pattern with Alt-F10 and Mod-/ shortcuts.

FloatingMenu lives in @domternal/extension-block-menu. The headless FloatingMenuController and item types live in @domternal/core so framework wrappers can import them without pulling in the full block-menu package.

Not included in StarterKit. Install @domternal/extension-block-menu and add it explicitly.

Click into the editor and press Enter to create an empty paragraph - the floating menu appears with all block-insert options. Or press Alt-F10 / Mod-/ to enter the menu via keyboard.

Click to try it out
Terminal window
pnpm add @domternal/extension-block-menu
import { StarterKit } from '@domternal/core';
import { FloatingMenu } from '@domternal/extension-block-menu';
import { DomternalEditor, DomternalFloatingMenu } from '@domternal/vanilla';
import '@domternal/theme';
const menuEl = document.getElementById('floating-menu')!;
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [
StarterKit,
FloatingMenu.configure({ element: menuEl }),
],
});
new DomternalFloatingMenu(menuEl, { editor: dm.editor });
FloatingMenu.configure({
element: menuEl, // required
shouldShow: defaultShouldShow, // override visibility predicate
offset: 0, // px offset from anchor
items: undefined, // override items list
keymap: { enterMenu: ['Alt-F10', 'Mod-/'] }, // shortcuts that focus first item
requireExplicitTrigger: false, // Notion-style: only opens via showFloatingMenu()
})
OptionTypeDefaultDescription
elementHTMLElement | nullnullThe HTML element that contains the menu. Required - if null, the plugin is not created. Framework wrappers create this element and pass it in.
shouldShow(props) => booleanSee belowCustom visibility predicate
offsetnumber0Pixel offset from anchor
itemsFloatingMenuItemsOverrideundefinedItems override. Array replaces defaults; function receives collected defaults and returns a new list
keymapFloatingMenuKeymap{ enterMenu: ['Alt-F10', 'Mod-/'] }Keyboard shortcuts for entering the menu via keyboard
requireExplicitTriggerbooleanfalseWhen true, the menu only appears via showFloatingMenu(view). Notion-style behavior.
function defaultShouldShow({ editor, state }): boolean {
if (!editor.isEditable) return false;
const { $from, empty } = state.selection;
if (!empty) return false;
if ($from.parent.type.name !== 'paragraph') return false;
if ($from.parent.content.size !== 0) return false;
if ($from.parentOffset !== 0) return false;
return true;
}

Shows when ALL conditions are met:

  1. The editor is editable
  2. The selection is empty (no range)
  3. The cursor’s parent is a paragraph
  4. The parent has zero content (empty)
  5. The cursor is at offset 0

When true, the menu does NOT auto-show on empty paragraphs. The ONLY way it appears is via:

  • A call to showFloatingMenu(view) (typically from BlockHandle’s + button)
  • The keyboard shortcuts in keymap.enterMenu (e.g. Mod-/)

This matches Notion’s behavior: empty rows show a placeholder hint, the / slash command is the keyboard trigger, and the menu opens via the gutter + button only.

FloatingMenu.configure({
element: menuEl,
requireExplicitTrigger: true,
})
import { showFloatingMenu, hideFloatingMenu } from '@domternal/extension-block-menu';
showFloatingMenu(editor.view); // explicit trigger
hideFloatingMenu(editor.view); // explicit dismiss

Both dispatch a plugin meta (key: 'dm:floatingMenuTrigger') so they work regardless of which PluginKey instance the menu was registered under.

Extensions contribute items via the addFloatingMenuItems() hook on the Extension class:

import { Extension } from '@domternal/core';
const MyExt = Extension.create({
name: 'myExt',
addFloatingMenuItems() {
return [
{
name: 'myItem',
label: 'My Item',
description: 'Inserts a my-item block',
icon: 'star',
group: 'Basic',
priority: 200,
keywords: ['my', 'custom'],
shortcut: 'M',
command: 'insertMyBlock',
commandArgs: [],
isDisabled: (editor) => !editor.can().insertMyBlock(),
hideWhenInside: ['codeBlock'],
},
];
},
});
interface FloatingMenuItem {
name: string; // unique id
label: string; // display text
description?: string; // longer hint
icon?: string; // icon key from defaultIcons or custom IconSet
group?: string; // group heading (preserves insertion order)
priority?: number; // default 100, higher first within group
keywords?: string[]; // additional terms for SlashCommand filtering
shortcut?: string; // visible hint (no shortcut wiring)
command: string | ((editor: Editor) => void); // string = editor.commands[name], function = direct call
commandArgs?: unknown[]; // args for string commands
isDisabled?: (editor: Editor) => boolean; // override default `editor.can()` check
hideWhenInside?: string[]; // hide when cursor inside these node types
}

The following extensions contribute items via addFloatingMenuItems:

ExtensionItemGroup
HeadingHeading 1, 2, 3, 4, 5, 6Headings
BulletListBullet listLists
OrderedListNumbered listLists
TaskListTo-do listLists
BlockquoteQuoteText
CodeBlockCode blockText
HorizontalRuleDividerText
extension-image’s ImageImageMedia
extension-table’s TableTableMedia
extension-details’s DetailsToggle list (details)Lists

Override with FloatingMenu.configure({ items: ... }) - array replaces defaults, function transforms them.

import { groupFloatingMenuItems } from '@domternal/core';
const groups = groupFloatingMenuItems(items);
// [{ name: 'Headings', items: [...] }, { name: 'Lists', items: [...] }, ...]

Groups items by .group preserving extension insertion order; sorts items within each group by priority descending.

Headless state machine that framework wrappers (and the Vanilla wrapper) use to render the menu UI.

import { FloatingMenuController, FLOATING_MENU_NO_FOCUS } from '@domternal/core';
const controller = new FloatingMenuController(editor, () => {
// onChange - re-render
}, override);
controller.subscribe();
// ...
controller.destroy();
FloatingMenuController.resolveItems(editor, override?): FloatingMenuItem[];
FloatingMenuController.executeItem(editor, item): void;

resolveItems exposed as static so the plugin can resolve once at init without constructing a controller. executeItem dispatches the item’s command - string -> editor.commands[name](...args); function -> direct call.

class FloatingMenuController {
groups: FloatingMenuGroup[]; // grouped items
flatItems: FloatingMenuItem[]; // flattened
disabledMap: ReadonlyMap<string, boolean>; // per-item disabled state
focusedIndex: number; // -1 = no focus, else 0..flatItems.length-1
isEntered: boolean; // true once user enters via keyboard
itemCount: number;
subscribe(): void; // wire transaction handler
destroy(): void; // unsubscribe + cleanup
enterMenu(): number; // focus first item, returns its index
leaveMenu(): void; // unfocus
next(): number; // ArrowDown, wraps at end
prev(): number; // ArrowUp, wraps at start
first(): number; // Home
last(): number; // End
setFocusedIndex(index: number): void; // direct set
execute(item: FloatingMenuItem): void; // dispatch item's command
isDisabled(item: FloatingMenuItem): boolean;
focusedItem(): FloatingMenuItem | null;
}

FLOATING_MENU_NO_FOCUS constant equals -1.

When keymap.enterMenu is non-empty, those shortcuts focus the first item if the menu is visible:

  • Alt-F10 - WAI-ARIA recommendation for entering toolbars/menus
  • Mod-/ - modern slash-command-friendly shortcut

Inside the menu (after entering):

  • ArrowDown / ArrowUp - navigate items (wraps)
  • Home / End - first / last
  • Enter / Space - execute focused item
  • Escape - leave menu, return focus to editor

For framework wrappers or advanced use:

import { createFloatingMenuPlugin } from '@domternal/extension-block-menu';
import { PluginKey } from '@domternal/pm/state';
const plugin = createFloatingMenuPlugin({
pluginKey: new PluginKey('myFloatingMenu'),
editor,
element: menuEl,
shouldShow,
offset: 0,
keymap: { enterMenu: ['Alt-F10', 'Mod-/'] },
requireExplicitTrigger: false,
});

Useful when you need to instantiate the plugin yourself with a custom key (e.g. multiple editors on one page).

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

ClassDescription
.dm-floating-menuContainer (absolutely positioned, hidden by default, visible via [data-show])
.dm-floating-menu[data-show]Visible state
.dm-floating-menu-groupGroup container (flex column, gap 0.125rem)
.dm-floating-menu-group-labelSection heading (uppercase, 0.6875rem, 600 weight)
.dm-floating-menu-itemButton-like row (flex, padding 0.3125rem 0.5rem, roving tabindex target)
.dm-floating-menu-item-iconIcon span (1.25rem square)
.dm-floating-menu-item-labelLabel (flex 1, ellipsis on overflow)
.dm-floating-menu-item-shortcutShortcut chip (code font, 0.6875rem)

Visibility uses visibility: hidden / visible + opacity: 0 / 1 controlled by the data-show attribute on the root. Container background is opaque (not translucent) so nested content is fully occluded.

  • role="menu" + aria-label="Floating menu" on the root
  • role="menuitem" on each item
  • Roving tabindex - only the focused item is in Tab order
  • Active item gets aria-current or aria-selected (framework-dependent)
  • Keyboard nav (ArrowDown/Up/Home/End/Enter/Space/Escape)
  • prefers-reduced-motion reduces animations
// From @domternal/extension-block-menu (the extension + plugin):
import {
FloatingMenu,
createFloatingMenuPlugin,
floatingMenuPluginKey,
showFloatingMenu,
hideFloatingMenu,
} from '@domternal/extension-block-menu';
import type {
FloatingMenuOptions,
CreateFloatingMenuPluginOptions,
FloatingMenuKeymap,
} from '@domternal/extension-block-menu';
// From @domternal/core (the controller + types):
import {
FloatingMenuController,
FLOATING_MENU_NO_FOCUS,
groupFloatingMenuItems,
} from '@domternal/core';
import type {
FloatingMenuItem,
FloatingMenuItemsOverride,
FloatingMenuGroup,
} from '@domternal/core';

@domternal/extension-block-menu - FloatingMenu.ts @domternal/core - FloatingMenuController.ts