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

Notion set the bar for how editing should feel: type / to insert anything, grab a handle to drag blocks around, nest them, recolor them, never think about a toolbar. React developers get a dozen libraries chasing that bar. Angular developers usually get told to wrap one of them and hope.

This tutorial builds the real thing natively: a Notion-style block editor in one standalone Angular component, about 80 lines, signals-based, running on zoneless Angular. We use Domternal, an MIT-licensed rich text editor built on ProseMirror that ships actual Angular components, not React bindings in a trench coat. If you want to see the end result before committing, the Notion Mode guide has a live demo. Every snippet below was run, not just compiled.

Building in React instead? The same editor, same steps: Build a Notion-Style Editor in React.

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/angular @domternal/extension-block-controls

Four packages: @domternal/core is the headless editor engine, @domternal/theme is the default stylesheet (plain CSS variables, fully overridable), @domternal/angular ships the standalone components, and @domternal/extension-block-controls is the block layer that powers everything Notion-ish - the handle, the slash menu, the context menu. We’ll add the table-of-contents extension in an optional step at the end.

The theme is global CSS, so it goes in styles.scss rather than a component:

styles.scss
@use '@domternal/theme';

Step 2: A minimal editor

notion-editor.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { DomternalEditorComponent } from '@domternal/angular';
import { StarterKit } from '@domternal/core';
@Component({
selector: 'app-notion-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DomternalEditorComponent],
template: `
<domternal-editor [extensions]="extensions" [content]="content" />
`,
})
export class NotionEditorComponent {
content = '<p>Hello world</p>';
extensions = [StarterKit];
}

That’s a working rich text editor. Three things worth knowing before we go further:

Step 3: Turn it into a Notion-style editor

Now the fun part. The block layer is plain editor extensions - add them to the array, put one CSS class on the component, done:

notion-editor.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { DomternalEditorComponent } from '@domternal/angular';
import { StarterKit, Placeholder, UniqueID } from '@domternal/core';
import {
BlockHandle,
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
} from '@domternal/extension-block-controls';
@Component({
selector: 'app-notion-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DomternalEditorComponent],
template: `
<domternal-editor
class="dm-notion-mode"
[extensions]="extensions"
[content]="content"
/>
`,
})
export class NotionEditorComponent {
content = '<h1>My page</h1><p></p>';
extensions = [
StarterKit,
UniqueID,
Placeholder.configure({
placeholder: ({ node }) =>
node.type.name === 'paragraph' ? "Press '/' for commands" : '',
}),
BlockHandle.configure({ nested: true }),
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
];
}

What each piece does:

One layout note: the drag handle lives in the margin about 3.5rem to the left of the content column. Give the editor’s container that breathing room and don’t clamp it with overflow: hidden, or the handle gets clipped.

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

The menus with visible UI - bubble menu, floating insert menu, color picker - are Angular components, not extensions. That split is deliberate: extensions run headless inside the editor, components render UI and register their own positioning plugins. So BubbleMenu never appears in the extensions array; you drop <domternal-bubble-menu> in the template instead. The color picker is the one hybrid: the NotionColorPicker extension owns the palette and the bubble menu’s A trigger, while the Angular component renders the panel.

notion-editor.component.ts
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
DomternalEditorComponent,
DomternalBubbleMenuComponent,
DomternalFloatingMenuComponent,
DomternalNotionColorPickerComponent,
} from '@domternal/angular';
import {
StarterKit,
Placeholder,
UniqueID,
TextStyle,
TextColor,
Highlight,
BlockColor,
NotionColorPicker,
ListIndent,
} from '@domternal/core';
import type { Editor } from '@domternal/core';
import {
BlockHandle,
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
} from '@domternal/extension-block-controls';
@Component({
selector: 'app-notion-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DomternalEditorComponent,
DomternalBubbleMenuComponent,
DomternalFloatingMenuComponent,
DomternalNotionColorPickerComponent,
],
template: `
<domternal-editor
class="dm-notion-mode"
[extensions]="extensions"
[content]="content"
(editorCreated)="editor.set($event)"
/>
@if (editor(); as ed) {
<domternal-bubble-menu [editor]="ed" />
<domternal-floating-menu [editor]="ed" [requireExplicitTrigger]="true" />
<domternal-notion-color-picker [editor]="ed" />
}
`,
})
export class NotionEditorComponent {
editor = signal<Editor | null>(null);
content = '<h1>My page</h1><p></p>';
extensions = [
StarterKit,
UniqueID,
Placeholder.configure({
placeholder: ({ node }) =>
node.type.name === 'paragraph' ? "Press '/' for commands" : '',
}),
TextStyle,
TextColor,
Highlight,
BlockColor,
NotionColorPicker,
ListIndent,
BlockHandle.configure({ nested: true }),
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
];
}

New pieces:

Select some text and the bubble menu appears with inline formatting:

Step 5: Nested drag-and-drop

No new code - nested: true from step 3 already did the work. Since Domternal 0.8.0 the drop model is gap-first: while dragging, the indicator tracks the nearest gap between blocks, and moving the pointer horizontally picks the nesting depth. A paragraph dropped on a list item’s child slot becomes an indented child block of that item, and a dragged list item keeps its own kind wherever it lands - it joins a same-kind list at the target or becomes its own single-item list. To-dos stay to-dos, bullets stay bullets.

One schema caution: Domternal’s list items use the strict Notion shape out of the box - a paragraph first, then any nested blocks. Documents saved with other editors, or with Domternal before 0.7, may have list items whose first child isn’t a paragraph, and those won’t parse until migrated. The List Item docs cover the migration.

Step 6 (optional): A floating outline

The scroll-spy outline that lives on the page edge is its own package:

Terminal window
npm install @domternal/extension-toc
import { TableOfContents, FloatingTocOutline } from '@domternal/extension-toc';
extensions = [
// ...everything from step 4,
TableOfContents,
FloatingTocOutline.configure({ anchor: 'viewport' }),
];

Two things to know:

Running zoneless?

So is this tutorial. Everything above runs on zoneless Angular with provideZonelessChangeDetection - the components are signal-driven, so ProseMirror events land as signal writes and OnPush templates update without zone.js or a single markForCheck(). The one rule: if you attach your own listeners with editor.on(...), wrap the handler body in ngZone.run() on zone-based apps - the Angular guide covers the pattern.

The full component

notion-editor.component.ts
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
DomternalEditorComponent,
DomternalBubbleMenuComponent,
DomternalFloatingMenuComponent,
DomternalNotionColorPickerComponent,
} from '@domternal/angular';
import {
StarterKit,
Placeholder,
UniqueID,
TextStyle,
TextColor,
Highlight,
BlockColor,
NotionColorPicker,
ListIndent,
} from '@domternal/core';
import type { Editor } from '@domternal/core';
import {
BlockHandle,
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
} from '@domternal/extension-block-controls';
import { TableOfContents, FloatingTocOutline } from '@domternal/extension-toc';
@Component({
selector: 'app-notion-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DomternalEditorComponent,
DomternalBubbleMenuComponent,
DomternalFloatingMenuComponent,
DomternalNotionColorPickerComponent,
],
template: `
<domternal-editor
class="dm-notion-mode"
[extensions]="extensions"
[content]="content"
(editorCreated)="editor.set($event)"
/>
@if (editor(); as ed) {
<domternal-bubble-menu [editor]="ed" />
<domternal-floating-menu [editor]="ed" [requireExplicitTrigger]="true" />
<domternal-notion-color-picker [editor]="ed" />
}
`,
})
export class NotionEditorComponent {
editor = signal<Editor | null>(null);
content = '<h1>My page</h1><p></p>';
extensions = [
StarterKit,
UniqueID,
Placeholder.configure({
placeholder: ({ node }) =>
node.type.name === 'paragraph' ? "Press '/' for commands" : '',
}),
TextStyle,
TextColor,
Highlight,
BlockColor,
NotionColorPicker,
ListIndent,
BlockHandle.configure({ nested: true }),
BlockContextMenu,
SlashCommand,
SmartPaste,
KeyboardReorder,
TableOfContents,
FloatingTocOutline.configure({ anchor: 'viewport' }),
];
}

That’s the whole thing: slash commands, drag handles with nested drag-and-drop, a block context menu with Turn into and colors, a bubble menu, keyboard block reordering and a scroll-spy outline - in one standalone component.

Where to go next