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.
When to use
Section titled “When to use”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
What you get
Section titled “What you get”- 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/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.
In a read-only editor
Section titled “In a read-only editor”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.
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-controls | Hover gutter, drag, + button |
BlockContextMenu | @domternal/extension-block-controls | Delete / Duplicate / Turn into / Colors / Copy link |
KeyboardReorder | @domternal/extension-block-controls | Mod-Shift-Up/Down moves blocks |
SlashCommand | @domternal/extension-block-controls | / insert popup |
SmartPaste | @domternal/extension-block-controls | Block-format-preserving paste |
FloatingMenu (with requireExplicitTrigger) | @domternal/extension-block-controls | 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 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/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 |
preset: 'notion' editor option | @domternal/core | Paints 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.
The preset option
Section titled “The preset option”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-modeto its.dm-editorhost 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:
- An explicit
presetoption always wins, sopreset: 'classic'opts out even inside a.dm-notion-modehost - Otherwise, a
dm-notion-modeclass on or above the editor means'notion' - 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.
Installation
Section titled “Installation”pnpm add @domternal/core @domternal/theme @domternal/extension-block-controls @domternal/extension-toc @domternal/extension-code-block-lowlightnpm install @domternal/core @domternal/theme @domternal/extension-block-controls @domternal/extension-toc @domternal/extension-code-block-lowlightyarn add @domternal/core @domternal/theme @domternal/extension-block-controls @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-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 });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-controls';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, 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 preset="notion" [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-controls';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
const 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({ requireExplicitTrigger: true }), TableOfContents, FloatingTocOutline, TableOfContentsBlock, BubbleMenu,];
export function NotionEditor() { return ( <Domternal extensions={extensions} preset="notion"> <Domternal.Content /> <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-controls';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
const 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({ requireExplicitTrigger: true }), TableOfContents, FloatingTocOutline, TableOfContentsBlock, BubbleMenu,];</script>
<template> <Domternal :extensions="extensions" preset="notion"> <Domternal.Content /> <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})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.
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})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.
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”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.
| Surface | Dispatches | Listens |
|---|---|---|
BlockHandle | On + click and on drag start | Yes (ignored while a BlockContextMenu is opening, so the handle stays anchored) |
SlashCommand | On activation | Yes (its own broadcast is suppressed so it does not close itself) |
BlockContextMenu | On open | Yes |
| Table row / column / cell handles | On handle click | No |
BubbleMenu (core) | No | Yes |
FloatingMenu plugin | No | Yes |
| Wrapper toolbar dropdowns | No | Yes |
| Wrapper bubble-menu dropdown panels | No | Yes |
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});Styling
Section titled “Styling”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.
Key CSS custom properties
Section titled “Key CSS custom properties”| Property | Default (notion mode) | Purpose |
|---|---|---|
--dm-notion-column-width | 44rem | The 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-height | 1.7 (global default 1.6) | Reading rhythm of the column |
--dm-editor-padding | 0 (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-gutter | 0 (global default 3rem) | Column reserved for BlockHandle inside the editor (notion mode pushes it OUTSIDE) |
--dm-block-handle-left | calc(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-top | 0.45em | Vertical alignment of task checkboxes (tuned for line-height 1.7) |
--dm-block-children-indent | calc(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.
List rhythm (v0.9.0)
Section titled “List rhythm (v0.9.0)”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 the0.75emcontainer 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.25emgap 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).
Customizing the column width
Section titled “Customizing the column width”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.
How wide the container has to be
Section titled “How wide the container has to be”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:
| needs | width |
|---|---|
| block handle | 44px (a 40px cluster plus its 4px gap) |
| docked sidebar, if you use one | its 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.
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 */}Placeholder in table cells
Section titled “Placeholder in table cells”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);}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 and by the block menu’s Copy link
- List Item + Task Item - strict schema migration
See also
Section titled “See also”- Build a Notion-Style Editor in React - step-by-step tutorial with videos
- Build a Notion-Style Editor in Angular - the same build on signals and zoneless Angular
- Notion-Style Block Editor for Any Framework - blog tour of the whole block layer with demo videos
- Block Menu - Notion-style block UX (handle, slash, reorder)
- Floating Menu - block-insert menu on empty paragraphs
- Notion Color Picker - Notion-style text/background color picker