Heading
The Heading node provides block-level heading elements (<h1> through <h4> by default). Headings support markdown-style input rules (# + space, ## + space, etc.), keyboard shortcuts, and a toolbar dropdown for switching between heading levels and normal text.
Live Playground
Section titled “Live Playground”Type # + space, ## + space, ### + space, or #### + space at the start of a line to create headings. Use Mod-Alt-1 through Mod-Alt-4 to toggle heading levels.
Vanilla preview · Angular components produce the same output
Vanilla preview · React components produce the same output
Vanilla preview · Vue components produce the same output
The buttons above the editor are custom HTML buttons wired to toggleHeading() and setParagraph(). Active state is tracked with editor.isActive().
Heading is included in StarterKit. If you are building a custom setup without StarterKit, add it manually:
import { Document, Text, Paragraph, Heading } from '@domternal/core';import { DomternalEditor } from '@domternal/vanilla';
const dm = new DomternalEditor(document.getElementById('editor')!, { extensions: [Document, Text, Paragraph, Heading], content: '<h1>Hello world</h1><p>Some text</p>',});import { Component, signal } from '@angular/core';import { DomternalEditorComponent } from '@domternal/angular';import { Editor, Document, Text, Paragraph, Heading } from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent], template: ` <domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)" /> `,})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [Document, Text, Paragraph, Heading]; content = '<h1>Hello world</h1><p>Some text</p>';}import { Domternal } from '@domternal/react';import { Document, Text, Paragraph, Heading } from '@domternal/core';
export default function Editor() { return ( <Domternal extensions={[Document, Text, Paragraph, Heading]} content="<h1>Hello world</h1><p>Some text</p>" > <Domternal.Content /> </Domternal> );}<script setup lang="ts">import { Domternal } from '@domternal/vue';import { Document, Text, Paragraph, Heading } from '@domternal/core';
const extensions = [Document, Text, Paragraph, Heading];</script>
<template> <Domternal :extensions="extensions" content="<h1>Hello world</h1><p>Some text</p>"> <Domternal.Content /> </Domternal></template>Schema
Section titled “Schema”| Property | Value |
|---|---|
| ProseMirror name | heading |
| Type | Node |
| Group | block |
| Content | inline* (zero or more inline nodes) |
| Defining | Yes |
| HTML tag | <h1> through <h4> (based on level) |
The defining property means that when you select a heading and paste content over it, the replacement keeps the heading type rather than converting to the pasted node type.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
levels | number[] | [1, 2, 3, 4] | Which heading levels to allow |
HTMLAttributes | Record<string, unknown> | {} | HTML attributes added to the heading element |
Custom heading levels
Section titled “Custom heading levels”import { Heading } from '@domternal/core';
// Only allow h1 and h2const CustomHeading = Heading.configure({ levels: [1, 2],});This restricts both the input rules and keyboard shortcuts to only the configured levels. For example, ### + space would not trigger a heading conversion if level 3 is not included.
Custom HTML attributes
Section titled “Custom HTML attributes”import { Heading } from '@domternal/core';
const CustomHeading = Heading.configure({ HTMLAttributes: { class: 'my-heading' },});Attributes
Section titled “Attributes”| Attribute | Type | Default | Description |
|---|---|---|---|
level | number | 1 | The heading level (1-4) |
The level attribute is parsed from the HTML tag name (<h1> = level 1, <h2> = level 2, etc.) and is not rendered as an HTML attribute. Instead, it determines which tag is used in the output.
Commands
Section titled “Commands”| Command | Description |
|---|---|
setHeading({ level }) | Convert the current block to a heading at the given level |
toggleHeading({ level }) | Toggle between heading and paragraph |
// Convert the current block to an h2editor.commands.setHeading({ level: 2 });
// Toggle between h1 and paragrapheditor.commands.toggleHeading({ level: 1 });
// With chainingeditor.chain().focus().toggleHeading({ level: 3 }).run();setHeading returns false if the requested level is not in the configured levels array. toggleHeading converts a heading back to a paragraph if the current block is already a heading at that level.
Keyboard shortcuts
Section titled “Keyboard shortcuts”| Shortcut | Cursor position | Command |
|---|---|---|
Mod-Alt-1 | any | toggleHeading({ level: 1 }) |
Mod-Alt-2 | any | toggleHeading({ level: 2 }) |
Mod-Alt-3 | any | toggleHeading({ level: 3 }) |
Mod-Alt-4 | any | toggleHeading({ level: 4 }) |
Enter | End of heading, non-empty | Insert paragraph as next sibling (NEW v0.7.0) |
Enter | Empty heading | Convert in place to paragraph (NEW v0.7.0) |
Enter | Mid/start of heading | Default split - both halves stay heading |
Backspace | Start of heading | Convert heading to paragraph |
Shortcuts are generated dynamically from the levels option. If you configure levels: [1, 2], only Mod-Alt-1 and Mod-Alt-2 are registered.
Notion-style Enter behavior (v0.7.0)
Section titled “Notion-style Enter behavior (v0.7.0)”The Enter key now has Notion-style behavior that runs BEFORE BaseKeymap:
- End of a non-empty heading: Inserts a new paragraph as the next sibling and moves the caret into it. No need to type a manual paragraph after a heading.
- Empty heading: Converts the heading IN PLACE to a paragraph (no extra block created). Useful for exiting an accidental
#input rule with a single keystroke. - Middle or start of a non-empty heading: Default split - the text after the cursor moves into a new heading of the same level.
The schema is validated before insertion: parent must accept paragraph at the position. This avoids breaking nested-in-listItem cases where the parent doesn’t allow paragraph siblings.
Backspace behavior
Section titled “Backspace behavior”The Backspace behavior converts a heading to a paragraph when the cursor is at position 0 inside the heading and the selection is empty. This lets users easily “undo” a heading by pressing backspace at the beginning of the line. Parity with the Enter behavior above.
/h1 inside a list-item label (v0.7.0)
Section titled “/h1 inside a list-item label (v0.7.0)”When a user types /h1 (slash command) in a list-item label and selects “Heading 1”, the item dissolves via a setBlockType lift fallback. This prevents an awkward “heading inside listItem label paragraph” state and matches Notion’s behavior.
Input rules
Section titled “Input rules”| Input | Result |
|---|---|
# + space | Heading level 1 |
## + space | Heading level 2 |
### + space | Heading level 3 |
#### + space | Heading level 4 |
Type the hash characters at the start of a new line, then press space. The line converts to a heading at the matching level. Only levels included in the levels option are recognized.
Toolbar items
Section titled “Toolbar items”Heading registers a dropdown in the toolbar with the name heading in group blocks at priority 200.
The dropdown contains:
| Item | Command | Icon | Shortcut |
|---|---|---|---|
| Normal text | setParagraph | textT | Mod-Alt-0 |
| Heading 1 | toggleHeading({ level: 1 }) | textHOne | Mod-Alt-1 |
| Heading 2 | toggleHeading({ level: 2 }) | textHTwo | Mod-Alt-2 |
| Heading 3 | toggleHeading({ level: 3 }) | textHThree | Mod-Alt-3 |
| Heading 4 | toggleHeading({ level: 4 }) | textHFour | Mod-Alt-4 |
The dropdown uses dynamicIcon: true, which means the dropdown button icon changes to match the currently active heading level.
JSON representation
Section titled “JSON representation”{ "type": "heading", "attrs": { "level": 2 }, "content": [ { "type": "text", "text": "Hello world" } ]}An empty heading:
{ "type": "heading", "attrs": { "level": 1 }}Source
Section titled “Source”@domternal/core - Heading.ts