Skip to content

Notion Mode

Notion mode is the Notion-style editor experience layered on top of Domternal: centered content, side gutter with a drag handle, slash command, block context menu, named-token color picker, floating Table of Contents, and a requireExplicitTrigger-style floating menu (no auto-show on empty paragraphs).

All four wrappers (Angular, React, Vue, Vanilla) support Notion mode with the same feature set. This guide covers the cross-framework setup; the per-framework guides cover wrapper-specific details.

Use Notion mode when you need:

  • A block-based editing experience like Notion: drag handle, slash command, block context menu
  • A clean canvas with hover-revealed UI rather than a permanent toolbar
  • Nested-drag support so users can drop blocks inside list items as children

Skip it if:

  • Your users expect Word-style editing with a persistent top toolbar
  • Your editor lives in a narrow column where drag handles would overflow
  • You target mobile-first input where drag-and-drop UX is awkward
  • Block handle appears in the side gutter on hover, with a + button and a ⋮⋮ drag handle (Notion’s [+][⋮⋮] order, drag grip adjacent to the block)
  • Drag-to-reorder with a custom drop indicator. The indicator snaps to the nearest gap between blocks; the pointer’s horizontal position picks the nesting depth (drag right to nest, left to outdent). Dropping a non-list block into a list keeps its type and splits the list around it.
  • Slash command - type / to open the insert popup with all available block types
  • Block context menu - click the drag handle for Delete / Duplicate / Turn into / Colors / Copy link
  • Notion color picker - the bubble menu has an “A” trigger that opens a named-token color picker (9 colors text + 9 colors background, plus default)
  • Block-level colors persist across “Turn into” transformations
  • Smart paste - heading-into-heading and trailing Shift+Enter paste behave correctly
  • Keyboard reorder - Mod-Shift-ArrowUp/Down moves the current top-level block
  • Floating Table of Contents - sticky outline with hover-expanded card; tracks the active heading via IntersectionObserver
  • Inline /toc block - insertable atom node renders a reactive heading list
  • Notion-strict list/task UX - listItem/taskItem schema is paragraph block*. Children-zone Enter inserts sibling in the item; Backspace at offset 0 lifts as top-level paragraph.

Since v0.14.0 the whole Notion-mode hover layer stands down when editor.isEditable is false: no block handle is rendered, a handle already on screen retracts as soon as setEditable(false) lands, and the bubble menu, floating menu and table chrome are gated too. You do not need to tear extensions out for a published or read-only view. See BlockHandle for the handle rule and the Toolbar guide for the allowReadOnly opt-in on toolbar items.

PiecePackagePurpose
StarterKit (without codeBlock)@domternal/coreBase extensions
CodeBlockLowlight@domternal/extension-code-block-lowlightReplaces StarterKit’s codeBlock with syntax-highlighted blocks
BlockHandle@domternal/extension-block-controlsHover gutter, drag, + button
BlockContextMenu@domternal/extension-block-controlsDelete / Duplicate / Turn into / Colors / Copy link
KeyboardReorder@domternal/extension-block-controlsMod-Shift-Up/Down moves blocks
SlashCommand@domternal/extension-block-controls/ insert popup
SmartPaste@domternal/extension-block-controlsBlock-format-preserving paste
FloatingMenu (with requireExplicitTrigger)@domternal/extension-block-controlsBlock-insert menu (opens only on + button or Mod-/)
NotionColorPicker@domternal/core”A” bubble-menu trigger + colorToken/bgToken attrs on textStyle
BlockColor@domternal/coreBlock-level bgColor/textColor attrs (Colors picker in context menu)
UniqueID@domternal/coreStable ids on 12 block types. Required by TableOfContents anchors AND by the block context menu’s Copy link, which is not rendered without it. Since v0.14.0 table is one of the default types, so a table is addressable by id like every other block.
ListIndent@domternal/coreTab/Shift-Tab at list boundaries
Placeholder@domternal/coreNotion-style empty-paragraph placeholder hint
TableOfContents@domternal/extension-tocHeading observer + scrollToHeading
FloatingTocOutline@domternal/extension-tocSticky outline UI
TableOfContentsBlock@domternal/extension-tocInline /toc atom node
preset: 'notion' editor option@domternal/corePaints the .dm-notion-mode theme class (centered content, side gutter, no card frame) and switches preset-aware extensions to their Notion behavior. See The preset option.

Optional but recommended: the Markdown extension from @domternal/extension-markdown adds Notion-style Markdown paste (a pasted ## heading or - [ ] task list converts to real blocks) plus .md import and export commands. Add it to the extension list like any other entry; the live demos on this site and the framework demo apps include it.

Notion mode is declared once, on the editor:

new Editor({ extensions, preset: 'notion' })

Every wrapper forwards it: useEditor({ preset: 'notion' }) in React and Vue, preset="notion" on the Angular <domternal-editor> and on the React/Vue <Domternal> and <DomternalEditor> components, and new DomternalEditor(el, { preset: 'notion' }) in vanilla.

The option does two things:

  • Paints the theme class. The editor adds .dm-notion-mode to its .dm-editor host on mount and removes it again on destroy (only if it added the class itself). You no longer write the class anywhere.
  • Switches preset-aware behavior. Extensions read the resolved preset via editor.preset: the default bubble menu contexts lead with Ask AI and include link and text-align, and the Image bubble menu offers align controls (move the picture, text stays below) instead of float controls (text wraps around it).

The class keeps working as before: an editor without the option that sits inside a .dm-notion-mode element resolves to the Notion preset, so existing setups need no change. Precedence, resolved on every read of editor.preset:

  1. An explicit preset option always wins, so preset: 'classic' opts out even inside a .dm-notion-mode host
  2. Otherwise, a dm-notion-mode class on or above the editor means 'notion'
  3. Otherwise 'classic'

preset is a create-time option. Toggling the class at runtime is picked up by editor.preset on the next read, but menus that were already collected (for example the image placement buttons) are cached per editor; recreate the editor to switch a live instance cleanly.

Terminal window
pnpm add @domternal/core @domternal/theme @domternal/extension-block-controls @domternal/extension-toc @domternal/extension-code-block-lowlight

Plus your framework wrapper of choice: @domternal/angular, @domternal/react, @domternal/vue, or @domternal/vanilla.

import {
StarterKit, BubbleMenu, NotionColorPicker, BlockColor,
UniqueID, ListIndent, Placeholder,
} from '@domternal/core';
import {
BlockHandle, BlockContextMenu, SlashCommand, SmartPaste,
KeyboardReorder, FloatingMenu,
} from '@domternal/extension-block-controls';
import {
TableOfContents, FloatingTocOutline, TableOfContentsBlock,
} from '@domternal/extension-toc';
import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
import {
DomternalEditor, DomternalBubbleMenu, DomternalFloatingMenu,
DomternalNotionColorPicker,
} from '@domternal/vanilla';
import '@domternal/theme';
const editorEl = document.getElementById('editor')!;
const bubbleEl = document.getElementById('bubble')!;
const floatingEl = document.getElementById('floating')!;
const dm = new DomternalEditor(editorEl, {
preset: 'notion',
extensions: [
StarterKit.configure({ codeBlock: false }),
CodeBlockLowlight,
UniqueID,
Placeholder.configure({ placeholder: "Press '/' for commands" }),
BlockColor,
NotionColorPicker,
ListIndent,
BlockHandle.configure({ nested: true }),
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
FloatingMenu.configure({ element: floatingEl, requireExplicitTrigger: true }),
TableOfContents,
FloatingTocOutline.configure({ anchor: 'editor' }),
TableOfContentsBlock,
BubbleMenu.configure({ element: bubbleEl }),
],
});
new DomternalBubbleMenu(bubbleEl, { editor: dm.editor });
new DomternalFloatingMenu(floatingEl, { editor: dm.editor });
new DomternalNotionColorPicker({ editor: dm.editor });
NotionColorPicker.configure({
palette: ['slate', 'rose', 'amber', 'emerald'], // requires matching --dm-block-text-* and --dm-block-bg-* variables in your theme CSS
})
BlockColor.configure({
bgColors: ['slate', 'rose', 'amber', 'emerald'],
textColors: ['slate', 'rose', 'amber', 'emerald'],
})

SlashCommand consumes items from the same addFloatingMenuItems hook as FloatingMenu. Override the items list:

SlashCommand.configure({
items: (defaults, editor) => [
...defaults,
{
name: 'myBlock',
label: 'My Custom Block',
icon: 'star',
group: 'Custom',
command: 'insertMyBlock',
},
],
})
BlockHandle.configure({
nested: {
allowedNodes: ['listItem', 'taskItem', 'detailsContent'], // extend nested resolution
promoteOnEdge: 'left', // shallower ancestor wins near left edge
},
nestThreshold: 48, // require more X displacement before committing to nested-child drop
})

nested takes four more fields this example does not show: allowedContainers (scope nested resolution to descendants of given ancestors), matchers / defaultMatchers (append to or replace the built-in block matchers) and the experimental anchorContainers (per-block handles anchored to a side-by-side container’s left edge, which must be paired with a dropZoneProviders entry). See the BlockHandle nested mode options for the full table.

By default BlockContextMenu writes #<blockId> appended to the current URL. Override for client-side routing:

BlockContextMenu.configure({
onCopyLink: (blockId, editor) => `https://myapp.com/docs/${docId}#${blockId}`,
})
BlockContextMenu.configure({
turnIntoTargets: [
{ label: 'Paragraph', icon: 'textT', nodeType: 'paragraph' },
{ label: 'Heading 1', icon: 'textHOne', nodeType: 'heading', attrs: { level: 1 } },
// omit other targets to curate the list
],
})
BlockContextMenu.configure({
turnIntoEnabled: false, // hide "Turn into" section
blockColorEnabled: false, // hide Colors row (still works when BlockColor not loaded either)
copyLinkEnabled: false, // hide Copy link
})

The menu is not limited to its built-in entries either: any extension can contribute one with the addBlockMenuItems() hook (v0.14.0), declaring a group (primary, colors, turnInto, collaboration) and an optional order. Contributed entries keep the menu’s role="menuitem", roving tabindex and arrow-key navigation. See Contributed items.

BlockContextMenu dispatches dm:copy-link-success and dm:copy-link-error events on the editor’s .dm-editor element. Add a listener to show toasts:

const editorEl = dm.editor.view.dom.closest('.dm-editor')!;
function showToast(message: string, kind: 'success' | 'error') {
const toast = document.createElement('div');
toast.className = kind === 'error'
? 'notion-demo-toast notion-demo-toast--error'
: 'notion-demo-toast';
toast.setAttribute('role', kind === 'error' ? 'alert' : 'status');
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), kind === 'error' ? 2600 : 1800);
}
editorEl.addEventListener('dm:copy-link-success', () => showToast('Link copied', 'success'));
editorEl.addEventListener('dm:copy-link-error', () => showToast('Failed to copy', 'error'));

Notion-mode overlays cooperate via the dm:dismiss-overlays custom event, dispatched on the .dm-editor element with bubbles: false. Overlays that take over the screen broadcast it; overlays that must get out of the way listen for it. Not every overlay does both.

SurfaceDispatchesListens
BlockHandleOn + click and on drag startYes (ignored while a BlockContextMenu is opening, so the handle stays anchored)
SlashCommandOn activationYes (its own broadcast is suppressed so it does not close itself)
BlockContextMenuOn openYes
Table row / column / cell handlesOn handle clickNo
BubbleMenu (core)NoYes
FloatingMenu pluginNoYes
Wrapper toolbar dropdownsNoYes
Wrapper bubble-menu dropdown panelsNoYes

The bubble menu and its wrapper dropdown panels deliberately never broadcast: the core bubble-menu plugin listens for the event, so a dropdown that broadcast on open would hide the menu holding its own trigger. NotionColorPicker is not part of this protocol at all; it closes through its own document-level outside-click and Escape handlers.

Because the event does not bubble, listen on the .dm-editor element itself, not on an ancestor:

const editorEl = dm.editor.view.dom.closest('.dm-editor');
editorEl?.addEventListener('dm:dismiss-overlays', () => {
// close your custom overlay
});

The @domternal/theme package ships the .dm-notion-mode class, and preset: 'notion' paints it onto the .dm-editor host for you. It activates the Notion-style layout: a centered 44rem column, no card frame, generous line-height, and a side gutter for the block handle.

The measure sits on the content column (.ProseMirror), while the editor host spans its container. That split matters if you dock a sidebar: a docked panel makes room by sliding the column sideways, and it can only slide inside the editor host, so the centring whitespace either side of the column is the room it pushes into. Cap the host itself to the column’s width and a sidebar has nowhere to go and ends up over the text. Writing the class yourself still works, but its two effects have different reach. The layout comes from the compound selector .dm-editor.dm-notion-mode, so the class has to sit on the editor element itself; on a parent it paints nothing. Preset resolution walks upward instead: editor.preset reads view.dom.closest('.dm-notion-mode'), so the class anywhere on or above the editor resolves the preset to 'notion' and switches the preset-aware extensions. Written on a parent alone it therefore gives you Notion behavior with classic styling. Notion mode declares no font-size: body text stays at the classic 1rem in both modes, so toggling does not shift text size.

PropertyDefault (notion mode)Purpose
--dm-notion-column-width44remThe reading measure. Read twice: as the column’s own width, and halved inside --dm-block-handle-left so the handle knows where the centred column starts. Override this one property and both follow
--dm-editor-line-height1.7 (global default 1.6)Reading rhythm of the column
--dm-editor-padding0 (global default 1rem)Zeroed because the page wrapper supplies the white space around the column. A precondition of the handle offset, which assumes the text starts at the column’s left edge
--dm-block-handle-gutter0 (global default 3rem)Column reserved for BlockHandle inside the editor (notion mode pushes it OUTSIDE)
--dm-block-handle-leftcalc(max(50% - var(--dm-notion-column-width) * 0.5, 0px) - 2.75rem + var(--dm-panel-column-shift, 0px)) (global default 0.25rem)Horizontal offset of the handle cluster. Walks in from the host’s centre to the centred column’s edge, then backs off the 40px cluster plus a 4px gap, so it ends 4px before the text at any host width. The trailing term lets it follow a docked panel that slides the column; it resolves to 0px when nothing publishes a shift
--dm-task-checkbox-top0.45emVertical alignment of task checkboxes (tuned for line-height 1.7)
--dm-block-children-indentcalc(1.5 * var(--dm-editor-font-size, 1rem))Horizontal indent for blocks in the children-zone of a list item. Scales with the editor font size (was a fixed 1.5rem before v0.9.1).

.dm-editor.dm-notion-mode sets border: none, border-radius: 0, box-shadow: none and background: transparent as literal declarations, not token reads. Setting --dm-editor-border, --dm-editor-border-radius, --dm-editor-shadow or --dm-editor-bg therefore has no effect in Notion mode. To restore a frame, re-declare the properties themselves on .dm-editor.dm-notion-mode: the selector has equal specificity, so your stylesheet must load after @domternal/theme.

Notion mode tightens list spacing to match Notion’s chunked rhythm:

  • Adjacent top-level lists of any type (bullet next to ordered, list next to task) collapse to the within-item gap (0.25em) instead of the 0.75em container gap, so consecutive lists read as one block. Notion-mode only; direct children of .ProseMirror, so nesting is untouched.
  • Nested lists chunk to the same 0.25em gap under their parent item’s label, aligning with sibling children-zone blocks. This applies in all modes (it lives in the content stylesheet, not the notion-mode preset).

Set the token, on the editor host or any ancestor:

.dm-notion-mode {
--dm-notion-column-width: 48rem; /* default is 44rem */
}

Do not set max-width on .dm-notion-mode instead. That caps the editor host rather than the reading column, so the measure stays at whatever the token says while the whitespace a docked sidebar pushes into shrinks with the host. The block handle still tracks the column, since its offset is derived from the host’s centre and the token.

Notion mode gives the editor host no width of its own: it fills whatever you put it in, and the reading column is centred inside that. So the container decides how much whitespace exists either side, and two things are spent from it:

needswidth
block handle44px (a 40px cluster plus its 4px gap)
docked sidebar, if you use oneits own width plus a 36px gap to the column (the rail itself sits flush against the frame, with no inset of its own)

A container narrower than --dm-notion-column-width + 88px leaves the handle without room on that side, and it is then pushed outside the host and painted in the page margin rather than onto the text: the offset is derived from the host’s own centre and the column token, so the cluster ends 4px before the text at any host width. One sized for a 38rem column will not have room for a 44rem one, so widen the container in the same change as the measure. Watch for a container whose own width is fixed rather than a maximum, which will not grow no matter what you set further down.

:root {
--dm-block-text-blue: #3b82f6;
--dm-block-bg-blue: rgba(59, 130, 246, 0.1);
/* ... other tokens */
}

Notion mode suppresses the placeholder inside <td> and <th> so long hints like Press '/' for commands don’t wrap onto two lines. Re-enable:

.dm-editor.dm-notion-mode .ProseMirror td .is-empty::before,
.dm-editor.dm-notion-mode .ProseMirror th .is-empty::before {
content: attr(data-placeholder);
}

When you load the Notion mode stack, listItem and taskItem schemas become strict paragraph block*. If you load existing JSON content where a list item’s first child is NOT a paragraph, parsing will fail. See List Item and Task Item for migration patterns.