
Notion set the bar for how a block editor should feel: type / to insert anything, grab a handle to drag blocks around, nest list items with a drop, recolor a block from its context menu. In this tutorial we build exactly that in React, step by step, in about 60 lines of component code.
Everything here uses Domternal, an MIT-licensed rich text editor built on ProseMirror. If you want to see the end result first, the Notion Mode guide has a live demo of the full experience.
What you’ll build
- A clean, borderless writing surface that looks like a Notion page
- A slash menu: type
/and pick a block type - Drag handles with multi-level nested drag-and-drop (drag right to nest, left to outdent)
- A block context menu with Delete, Duplicate, Turn into, and Colors
- A bubble menu for inline formatting
- An optional floating outline that tracks your headings
Here’s the slash menu we’ll have by the end of step 3, recorded from this tutorial’s own component:
Step 1: Install
npm install @domternal/core @domternal/theme @domternal/react @domternal/extension-block-menuFour packages: the headless core, the default theme (plain CSS, fully overridable), the React bindings, and the block-menu extension that powers the Notion behaviors. We’ll add the table-of-contents extension later as an optional step.
Step 2: A minimal editor
Start with the smallest thing that works:
import { useEditor } from '@domternal/react';import { StarterKit } from '@domternal/core';import '@domternal/theme';
const extensions = [StarterKit];
export default function MyEditor() { const { editorRef } = useEditor({ extensions, content: '<p>Hello world</p>', });
return ( <div className="dm-editor"> <div ref={editorRef} /> </div> );}Three things worth knowing before we go further:
useEditorreturns{ editor, editorRef }. Theeditorinstance isnullon the first render because it’s created after mount, so anything that uses it should be guarded with{editor && ...}. You’ll see that pattern in a moment.- Define the
extensionsarray at module scope (or inuseMemo). The hook recreates the editor whenever the array identity changes, so an inline literal would tear the editor down on every render. - The
dm-editorclass on the wrapper is not decoration. The theme styles hang off it, and every floating element (bubble menu, popovers) positions itself inside it. In the hook pattern you own the wrapper, so you add the class yourself.
StarterKit bundles the document schema, lists, task lists, formatting marks, link handling, history and more, and any piece can be switched off with StarterKit.configure().
Step 3: Turn it into a Notion-style editor
Now the fun part. The Notion behaviors come from a set of extensions in @domternal/extension-block-menu, plus two classes on the wrapper:
import { useEditor } from '@domternal/react';import { StarterKit, Placeholder, UniqueID } from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder,} from '@domternal/extension-block-menu';import '@domternal/theme';
const extensions = [ StarterKit, UniqueID, Placeholder.configure({ placeholder: ({ node }) => node.type.name === 'paragraph' ? "Press '/' for commands" : '', }), BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder,];
export default function NotionEditor() { const { editorRef } = useEditor({ extensions, content: '<h1>My page</h1><p></p>', });
return ( <div className="dm-editor dm-notion-mode"> <div ref={editorRef} /> </div> );}What each piece does:
BlockHandlerenders the six-dot handle in the left gutter and makes blocks draggable.nested: trueis the option people forget: without it every drag resolves to top-level blocks and you get no drop-into-list nesting.BlockContextMenuis the menu behind the handle click: Delete, Duplicate, Turn into, and Copy link (which is whyUniqueIDis in the list - it gives every block a stable id).SlashCommandopens the insert menu when you type/at the start of a line. It only reacts to a genuinely typed slash, so pasted text never triggers it.SmartPastecleans up pasted content;KeyboardReorderadds Alt+ArrowUp/Down block reordering.- The
Placeholderfunction form shows the hint only on empty paragraphs. With a plain string the hint would repeat on every empty heading and quote, which gets noisy. dm-notion-modealongsidedm-editoris what makes it look like Notion: the theme drops the border and shadow, centers a 38rem column, and moves the drag handle fully outside the text column.
One layout note: in Notion mode the handle sits about 3.5rem to the left of the content column, so leave breathing room there - a centered column inside an overflow: hidden container that hugs the text will clip the handle.
The block handle and its context menu in action - hovering reveals the handle, clicking it opens the menu, and Turn into converts the paragraph to a to-do:
Step 4: Bubble menu and block colors
Inline formatting and colors are React components, not extensions. This is a deliberate split: in React, the menus render through React so they can use your context, portals and styling.
import { useEditor, DomternalBubbleMenu, DomternalFloatingMenu, DomternalNotionColorPicker,} from '@domternal/react';import { StarterKit, Placeholder, UniqueID, TextStyle, BlockColor, NotionColorPicker, ListIndent } from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder,} from '@domternal/extension-block-menu';import '@domternal/theme';
const extensions = [ StarterKit, UniqueID, Placeholder.configure({ placeholder: ({ node }) => node.type.name === 'paragraph' ? "Press '/' for commands" : '', }), TextStyle, BlockColor, NotionColorPicker, ListIndent, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder,];
export default function NotionEditor() { const { editor, editorRef } = useEditor({ extensions, content: '<h1>My page</h1><p></p>', });
return ( <div className="dm-editor dm-notion-mode"> <div ref={editorRef} /> {editor && ( <> <DomternalBubbleMenu editor={editor} /> <DomternalFloatingMenu editor={editor} requireExplicitTrigger /> <DomternalNotionColorPicker editor={editor} /> </> )} </div> );}New pieces:
DomternalBubbleMenuappears over a text selection with bold, italic, link and friends.DomternalFloatingMenuis the ”+” insert menu. TherequireExplicitTriggerprop keeps it quiet until you click the handle’s plus button or press Mod-/, which is the Notion behavior. Note that it’s a prop on the component - in React the menu components register their own plugins, so you don’t addFloatingMenuorBubbleMenuto the extensions array at all.BlockColor+NotionColorPickergive the context menu its Colors section with the 9-token Notion palette. The picker writes through theTextStylemark, which is whyTextStylejoins the extensions here - without it the editor throws at startup.ListIndentadds Tab/Shift+Tab indenting inside lists. It’s intentionally not in StarterKit’s defaults (Tab capture is wrong for plain forms), so Notion mode registers it explicitly.
Select some text and the bubble menu appears with inline formatting:
Step 5: Nested drag-and-drop
There’s nothing more to install for this - it’s what nested: true unlocks, and as of Domternal 0.8.0 the drop model is gap-first: the indicator snaps to the nearest gap between blocks, and your pointer’s horizontal position picks the depth. Drag a block to the right edge of a list item to nest it as a child; drag left to outdent it across ancestor levels. A to-do dropped into a bullet list converts to match its siblings, and a paragraph dropped inside a list splits the list cleanly instead of becoming a bullet.
One schema caution: the Notion stack switches list items to a strict “paragraph first, then blocks” shape. If you’re loading documents saved before adding these extensions and a list item starts with something other than a paragraph, it will fail to parse. The List Item docs cover the migration.
Step 6 (optional): A floating outline
Notion shows a little tick column on the right that expands into a page outline. That’s one extension package away:
npm install @domternal/extension-tocimport { TableOfContents, FloatingTocOutline } from '@domternal/extension-toc';
const extensions = [ // ...everything from step 4, TableOfContents, FloatingTocOutline.configure({ anchor: 'viewport' }),];TableOfContents is the headless observer that tracks headings; FloatingTocOutline renders the outline with scroll-spy highlighting. Two things to know:
- It depends on
UniqueIDfor heading anchors - we added it in step 3, but if you skip it the outline silently renders nothing, so keep them together. anchor: 'viewport'pins the outline to the right edge of the screen, which is what you want when the page itself scrolls (like a Notion page). The default'editor'anchor sits in the editor’s own gutter instead, which fits fixed-height editors. Details and options are in the Table of Contents docs.
Using Next.js?
Two notes and you’re done:
- Add
'use client'to the file - the components use hooks and context. - No
typeof windowguards are needed. The editor is created insideuseEffect, so nothing touches the DOM during server rendering;editoris simplynulluntil the client mounts.
The full component
import { useEditor, DomternalBubbleMenu, DomternalFloatingMenu, DomternalNotionColorPicker,} from '@domternal/react';import { StarterKit, Placeholder, UniqueID, TextStyle, BlockColor, NotionColorPicker, ListIndent,} from '@domternal/core';import { BlockHandle, BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder,} from '@domternal/extension-block-menu';import { TableOfContents, FloatingTocOutline } from '@domternal/extension-toc';import '@domternal/theme';
const extensions = [ StarterKit, UniqueID, Placeholder.configure({ placeholder: ({ node }) => node.type.name === 'paragraph' ? "Press '/' for commands" : '', }), TextStyle, BlockColor, NotionColorPicker, ListIndent, BlockHandle.configure({ nested: true }), BlockContextMenu, SlashCommand, SmartPaste, KeyboardReorder, TableOfContents, FloatingTocOutline.configure({ anchor: 'viewport' }),];
export default function NotionEditor() { const { editor, editorRef } = useEditor({ extensions, content: '<h1>My page</h1><p></p>', });
return ( <div className="dm-editor dm-notion-mode"> <div ref={editorRef} /> {editor && ( <> <DomternalBubbleMenu editor={editor} /> <DomternalFloatingMenu editor={editor} requireExplicitTrigger /> <DomternalNotionColorPicker editor={editor} /> </> )} </div> );}That’s the whole thing: a slash menu, draggable blocks with multi-level nesting, a context menu with colors, inline formatting, and a floating outline, in one component.
Where to go next
- Notion Mode guide - the full reference for everything in this tutorial, including custom slash items, palette theming and copy-link toasts
- Block Menu API - every option on BlockHandle, BlockContextMenu and SlashCommand
- React guide - the compound component API, controlled mode and
useEditorState - Theming - 160+ CSS custom properties when you want it to stop looking like Notion and start looking like you