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.
What you get
Section titled “What you get”- Block handle appears in the side gutter on hover, with a
⋮⋮drag handle and a+button - Drag-to-reorder with a custom drop indicator. Sibling vs. nested-child drop is decided by the X position over list items.
- 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/Downmoves the current top-level block - Floating Table of Contents - sticky outline with hover-expanded card; tracks the active heading via IntersectionObserver
- Inline
/tocblock - insertable atom node renders a reactive heading list - Notion-strict list/task UX -
listItem/taskItemschema isparagraph block*. Children-zone Enter inserts sibling in the item; Backspace at offset 0 lifts as top-level paragraph.
Required pieces
Section titled “Required pieces”| Piece | Package | Purpose |
|---|---|---|
StarterKit (without codeBlock) | @domternal/core | Base extensions |
CodeBlockLowlight | @domternal/extension-code-block-lowlight | Replaces StarterKit’s codeBlock with syntax-highlighted blocks |
BlockHandle | @domternal/extension-block-menu | Hover gutter, drag, + button |
BlockContextMenu | @domternal/extension-block-menu | Delete / Duplicate / Turn into / Colors / Copy link |
KeyboardReorder | @domternal/extension-block-menu | Mod-Shift-Up/Down moves blocks |
SlashCommand | @domternal/extension-block-menu | / insert popup |
SmartPaste | @domternal/extension-block-menu | Block-format-preserving paste |
FloatingMenu (with requireExplicitTrigger) | @domternal/extension-block-menu | Block-insert menu (opens only on + button or Mod-/) |
NotionColorPicker | @domternal/core | ”A” bubble-menu trigger + colorToken/bgToken attrs on textStyle |
BlockColor | @domternal/core | Block-level bgColor/textColor attrs (Colors picker in context menu) |
UniqueID | @domternal/core | Stable ids on headings (TOC anchor source) |
ListIndent | @domternal/core | Tab/Shift-Tab at list boundaries |
Placeholder | @domternal/core | Notion-style empty-paragraph placeholder hint |
TableOfContents | @domternal/extension-toc | Heading observer + scrollToHeading |
FloatingTocOutline | @domternal/extension-toc | Sticky outline UI |
TableOfContentsBlock | @domternal/extension-toc | Inline /toc atom node |
.dm-notion-mode CSS class | @domternal/theme | Centered content, side gutter, larger font |
Installation
Section titled “Installation”pnpm add @domternal/core @domternal/theme @domternal/extension-block-menu @domternal/extension-toc @domternal/extension-code-block-lowlightnpm install @domternal/core @domternal/theme @domternal/extension-block-menu @domternal/extension-toc @domternal/extension-code-block-lowlightyarn add @domternal/core @domternal/theme @domternal/extension-block-menu @domternal/extension-toc @domternal/extension-code-block-lowlightPlus your framework wrapper of choice: @domternal/angular, @domternal/react, @domternal/vue, or @domternal/vanilla.
Cross-framework setup
Section titled “Cross-framework setup”import { StarterKit, BubbleMenu, NotionColorPicker, BlockColor, UniqueID, ListIndent, Placeholder,} from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu,} from '@domternal/extension-block-menu';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')!;editorEl.classList.add('dm-notion-mode');
const dm = new DomternalEditor(editorEl, { extensions: [ StarterKit.configure({ codeBlock: false }), CodeBlockLowlight, UniqueID.configure({ types: ['heading', 'paragraph', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem'] }), 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 });import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalBubbleMenuComponent, DomternalFloatingMenuComponent, DomternalNotionColorPickerComponent,} from '@domternal/angular';import { Editor, StarterKit, BubbleMenu, NotionColorPicker, BlockColor, UniqueID, ListIndent, Placeholder,} from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu,} from '@domternal/extension-block-menu';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
@Component({ selector: 'app-notion-editor', imports: [ DomternalEditorComponent, DomternalBubbleMenuComponent, DomternalFloatingMenuComponent, DomternalNotionColorPickerComponent, ], templateUrl: './notion-editor.html',})export class NotionEditorComponent { editor = signal<Editor | null>(null); extensions = [ StarterKit.configure({ codeBlock: false }), CodeBlockLowlight, UniqueID.configure({ types: ['heading', 'paragraph', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem'] }), Placeholder.configure({ placeholder: "Press '/' for commands" }), BlockColor, NotionColorPicker, ListIndent, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu.configure({ requireExplicitTrigger: true }), TableOfContents, FloatingTocOutline, TableOfContentsBlock, BubbleMenu, ];}@if (editor(); as ed) { <domternal-bubble-menu [editor]="ed" /> <domternal-floating-menu [editor]="ed" /> <domternal-notion-color-picker [editor]="ed" />}<domternal-editor class="dm-notion-mode" [extensions]="extensions" (editorCreated)="editor.set($event)"/>import { Domternal, DomternalNotionColorPicker } from '@domternal/react';import { StarterKit, BubbleMenu, NotionColorPicker, BlockColor, UniqueID, ListIndent, Placeholder,} from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu,} from '@domternal/extension-block-menu';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
const extensions = [ StarterKit.configure({ codeBlock: false }), CodeBlockLowlight, UniqueID.configure({ types: ['heading', 'paragraph', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem'] }), Placeholder.configure({ placeholder: "Press '/' for commands" }), BlockColor, NotionColorPicker, ListIndent, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu.configure({ requireExplicitTrigger: true }), TableOfContents, FloatingTocOutline, TableOfContentsBlock, BubbleMenu,];
export function NotionEditor() { return ( <Domternal extensions={extensions}> <Domternal.Content className="dm-notion-mode" /> <Domternal.BubbleMenu /> <Domternal.FloatingMenu /> <DomternalNotionColorPicker /> </Domternal> );}<script setup lang="ts">import { Domternal, DomternalNotionColorPicker } from '@domternal/vue';import { StarterKit, BubbleMenu, NotionColorPicker, BlockColor, UniqueID, ListIndent, Placeholder,} from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu,} from '@domternal/extension-block-menu';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
const extensions = [ StarterKit.configure({ codeBlock: false }), CodeBlockLowlight, UniqueID.configure({ types: ['heading', 'paragraph', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem'] }), Placeholder.configure({ placeholder: "Press '/' for commands" }), BlockColor, NotionColorPicker, ListIndent, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, FloatingMenu.configure({ requireExplicitTrigger: true }), TableOfContents, FloatingTocOutline, TableOfContentsBlock, BubbleMenu,];</script>
<template> <Domternal :extensions="extensions"> <Domternal.Content class="dm-notion-mode" /> <Domternal.BubbleMenu /> <Domternal.FloatingMenu /> <DomternalNotionColorPicker /> </Domternal></template>Configuration cookbook
Section titled “Configuration cookbook”Customizing the color palette
Section titled “Customizing the color palette”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'],})Custom slash items
Section titled “Custom slash items”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', }, ],})Custom nested mode
Section titled “Custom nested mode”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})Custom copy-link URLs
Section titled “Custom copy-link URLs”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}`,})Custom Turn-into list
Section titled “Custom Turn-into list”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 ],})Disabling sections
Section titled “Disabling sections”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})Toast handling (Copy link feedback)
Section titled “Toast handling (Copy link feedback)”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'));@HostListener('dm:copy-link-success')onCopyLinkSuccess() { this.toast.show('Link copied', 'success');}@HostListener('dm:copy-link-error')onCopyLinkError() { this.toast.show('Failed to copy', 'error');}useEffect(() => { const el = editor.view.dom.closest('.dm-editor'); const onSuccess = () => pushToast('Link copied', 'success'); const onError = () => pushToast('Failed to copy', 'error'); el?.addEventListener('dm:copy-link-success', onSuccess); el?.addEventListener('dm:copy-link-error', onError); return () => { el?.removeEventListener('dm:copy-link-success', onSuccess); el?.removeEventListener('dm:copy-link-error', onError); };}, [editor]);onMounted(() => { const el = editor.value?.view.dom.closest('.dm-editor'); const onSuccess = () => pushToast('Link copied', 'success'); const onError = () => pushToast('Failed to copy', 'error'); el?.addEventListener('dm:copy-link-success', onSuccess); el?.addEventListener('dm:copy-link-error', onError); onUnmounted(() => { el?.removeEventListener('dm:copy-link-success', onSuccess); el?.removeEventListener('dm:copy-link-error', onError); });});Cross-overlay coordination
Section titled “Cross-overlay coordination”All Notion-mode overlays cooperate via the dm:dismiss-overlays custom event dispatched on the .dm-editor element. When one opens, it dispatches the event to close the others.
| Overlay | Dispatches | Listens |
|---|---|---|
BlockContextMenu | Open | Close |
SlashCommand | Open | Close |
FloatingMenu | Open (explicit trigger) | Close |
BubbleMenu | Open | Close |
NotionColorPicker | Open | Close |
To integrate your own overlays:
const editorEl = dm.editor.view.dom.closest('.dm-editor');editorEl?.addEventListener('dm:dismiss-overlays', () => { // close your custom overlay});Styling
Section titled “Styling”The @domternal/theme package ships the .dm-notion-mode class. Apply it to your .dm-editor element (or a parent) to activate Notion-style layout: centered content, larger font, generous line-height, side gutter for the block handle.
Key CSS custom properties
Section titled “Key CSS custom properties”| Property | Default (notion mode) | Purpose |
|---|---|---|
--dm-block-handle-gutter | 0 | Column reserved for BlockHandle inside the editor (notion mode pushes it OUTSIDE) |
--dm-block-handle-left | -3.5rem | Horizontal offset of the gutter (notion mode places it fully outside the content column) |
--dm-task-checkbox-top | 0.45em | Vertical alignment of task checkboxes (tuned for line-height 1.7) |
--dm-block-children-indent | 1.5rem | Horizontal indent for blocks in the children-zone of a list item |
Customizing the column width
Section titled “Customizing the column width”.dm-notion-mode { max-width: 48rem; /* default is 38rem */}Customizing the palette colors
Section titled “Customizing the palette colors”:root { --dm-block-text-blue: #3b82f6; --dm-block-bg-blue: rgba(59, 130, 246, 0.1); /* ... other tokens */}Breaking schema change reminder
Section titled “Breaking schema change reminder”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.
Cross-links
Section titled “Cross-links”- Block Menu - the 5 sub-extensions (BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder)
- Table of Contents - TableOfContents + FloatingTocOutline + TableOfContentsBlock
- Notion Color Picker - inline named-token colors
- Block Color - block-level bgColor/textColor
- List Indent - Tab/Shift-Tab at list boundaries
- Floating Menu - block-insert menu + items API
- Unique ID - peer required by TableOfContents
- List Item + Task Item - strict schema migration