Build a Notion-style editor in React, step by step

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

Here’s the slash menu we’ll have by the end of step 3, recorded from this tutorial’s own component:

Step 1: Install

Terminal window
npm install @domternal/core @domternal/theme @domternal/react @domternal/extension-block-menu

Four 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:

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:

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:

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:

Terminal window
npm install @domternal/extension-toc
import { 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:

Using Next.js?

Two notes and you’re done:

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