Skip to content

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.

Choose Heading when you need:

  • H1-H6 with configurable allowed levels (default: H1-H4)
  • A toolbar dropdown to switch between heading levels and paragraph
  • Markdown #, ##, ### shortcuts at the start of a line
  • Anchor IDs for deep-linking (pair with UniqueID and TableOfContents)

Skip it if:

  • Your editor renders body text only and structural hierarchy lives elsewhere
  • You enforce a single H1 at the document level (write a custom node instead)
  • You need collapsible sections under headings (use Details instead)

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.

Click to try it out

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>',
});
PropertyValue
ProseMirror nameheading
TypeNode
Groupblock
Contentinline* (zero or more inline nodes)
DefiningYes
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.

OptionTypeDefaultDescription
levelsnumber[][1, 2, 3, 4]Which heading levels to allow
HTMLAttributesRecord<string, unknown>{}HTML attributes added to the heading element
import { Heading } from '@domternal/core';
// Only allow h1 and h2
const 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.

You can also widen past the default. Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }) gives you a working schema, HTML parsing and rendering, input rules (##### + space, ###### + space) and keyboard shortcuts (Mod-Alt-5, Mod-Alt-6) for the extra levels. @domternal/theme applies its shared heading rules (line height, block inline padding) to h5 and h6 as well, and both carry a size: h5 at 1em and h6 at 0.9em with a muted color, continuing the ladder from h3 (1.25em) and h4 (1.1em). h5 used to have no rule at all, which left it on the browser default of 0.83em, so it rendered SMALLER than h6 and smaller than body text. That was an omission rather than a design, and it stayed invisible because the extension ships with levels 1 to 4.

What you do not get is menu surface area. The toolbar heading dropdown renders levels 1-4 only, and the slash / floating menu renders levels 1-3 only, so H5 and H6 are reachable through input rules, keyboard shortcuts and the commands API (setHeading({ level: 5 })) but never appear in a menu. With the caret inside an H5 or H6 the dropdown also has no matching item to highlight, so its dynamicIcon trigger falls back to the generic textH icon.

import { Heading } from '@domternal/core';
const CustomHeading = Heading.configure({
HTMLAttributes: { class: 'my-heading' },
});
AttributeTypeDefaultDescription
levelnumber1The 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.

CommandDescription
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 h2
editor.commands.setHeading({ level: 2 });
// Toggle between h1 and paragraph
editor.commands.toggleHeading({ level: 1 });
// With chaining
editor.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.

ShortcutCursor positionCommand
Mod-Alt-1anytoggleHeading({ level: 1 })
Mod-Alt-2anytoggleHeading({ level: 2 })
Mod-Alt-3anytoggleHeading({ level: 3 })
Mod-Alt-4anytoggleHeading({ level: 4 })
EnterEnd of heading, non-emptyInsert paragraph as next sibling (NEW v0.7.0)
EnterEmpty headingConvert in place to paragraph (NEW v0.7.0)
EnterMid/start of headingDefault split - both halves stay heading
BackspaceStart of headingConvert 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.

The Enter key now has Notion-style behavior that runs BEFORE BaseKeymap:

  1. 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.
  2. 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.
  3. 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.

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.

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.

InputResult
# + spaceHeading level 1
## + spaceHeading level 2
### + spaceHeading level 3
#### + spaceHeading 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.

Heading registers a dropdown in the toolbar with the name heading in group blocks at priority 200.

The dropdown contains:

ItemCommandIconShortcut
Normal textsetParagraphtextTMod-Alt-0
Heading 1toggleHeading({ level: 1 })textHOneMod-Alt-1
Heading 2toggleHeading({ level: 2 })textHTwoMod-Alt-2
Heading 3toggleHeading({ level: 3 })textHThreeMod-Alt-3
Heading 4toggleHeading({ level: 4 })textHFourMod-Alt-4

The dropdown uses dynamicIcon: true, which means the dropdown button icon changes to match the currently active heading level.

Heading also registers items in the Basic group of the slash command and floating menu, matched by the keywords heading, h<n> and title:

ItemLabelDescriptionIconShortcut hintPriority
heading-1Heading 1Big section headingtextHOne# 200
heading-2Heading 2Medium section headingtextHTwo## 190
heading-3Heading 3Small section headingtextHThree### 180

All three run toggleHeading({ level }). Priorities come from 210 - level * 10, so lower levels sort first. Only levels 1-3 appear here: level 4 is enabled by default but stays toolbar-only, and configuring higher levels does not add them either.

{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{ "type": "text", "text": "Hello world" }
]
}

An empty heading:

{
"type": "heading",
"attrs": { "level": 1 }
}

@domternal/core - Heading.ts