Skip to content

List Item

The ListItem node represents an individual <li> element inside a BulletList or OrderedList. It handles Enter key splitting (creating new items or exiting the list), and automatically includes the ListKeymap extension for Tab/Shift-Tab indentation.

You don’t need to add ListItem manually. It is automatically included when you add BulletList or OrderedList via their addExtensions(). If you are using StarterKit, ListItem is already included.

import { Document, Text, Paragraph, BulletList, OrderedList } from '@domternal/core';
import { DomternalEditor } from '@domternal/vanilla';
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [Document, Text, Paragraph, BulletList, OrderedList],
content: '<ul><li><p>First item</p></li><li><p>Second item</p></li></ul>',
});
PropertyValue
ProseMirror namelistItem
TypeNode
GroupNone (used as content of list nodes)
Contentparagraph block* (strict: paragraph label + optional children-zone)
DefiningYes
HTML tag<li>

The first child paragraph is the label - aligned with the bullet marker and the primary entry point for text. The remaining block* children make up the children-zone: nested content that renders indented below the label.

The defining property means that when you select a list item and paste content over it, the replacement keeps the list item wrapper.

Existing content where a list item’s first child is NOT a paragraph fails to parse with the strict schema. Three migration patterns:

Pattern 1: heading-first listItem (rare, intentional)

<!-- Before (v0.6) -->
<li><h2>Title</h2></li>
<!-- After (v0.7) - convert the heading to a paragraph -->
<li><p>Title</p></li>

Or, if you really want a heading inside, demote it into the children-zone with an empty label:

<li><p></p><h2>Title</h2></li>

Pattern 2: pure-paragraph listItem (common, no migration needed)

<!-- Before and After identical -->
<li><p>Item</p></li>

Pattern 3: nested-list-first listItem (uncommon but possible)

<!-- Before (v0.6) -->
<li><ul><li><p>nested</p></li></ul></li>
<!-- After (v0.7) - insert an empty paragraph as the label -->
<li><p></p><ul><li><p>nested</p></li></ul></li>

The schema enforcement makes children-zone semantics predictable: indentation is always rooted on a paragraph label aligned with the bullet/checkbox marker.

OptionTypeDefaultDescription
HTMLAttributesRecord<string, unknown>{}HTML attributes added to the <li> element
import { ListItem } from '@domternal/core';
const CustomListItem = ListItem.configure({
HTMLAttributes: { class: 'my-list-item' },
});
ShortcutCursor positionBehavior
EnterIn label (first child), non-emptySplit the list item at the cursor; creates a new item below
EnterIn label, emptyLift content out of the list (exit)
EnterIn children-zone, non-emptySplits in place; both halves stay in the same item
EnterIn children-zone, empty paragraphInserts an empty paragraph as a SIBLING inside the same list item (accumulate-on-Enter)
BackspaceIn label at offset 0Falls through to ListKeymap (lift)
BackspaceIn children-zone, empty at offset 0Lifts the empty paragraph as a top-level paragraph below the list
TabIn any list slotSink into nested list (sinkListItem via ListKeymap)
Shift-TabIn any list slotLift out one level (liftListItem via ListKeymap)
TabTop-level block right after a listIndent INTO the previous list as nested child (ListIndent)
Shift-TabLast nested child of last itemLift OUT of the list as top-level (ListIndent)

The Enter behavior depends on whether the cursor is in the label paragraph (first child) or the children-zone (subsequent blocks):

In the label (text aligned with the bullet):

  1. Non-empty, cursor in middle/end: Splits the list item, creating a new item with the text after the cursor (splitListItem).
  2. Empty: Lifts the content out of the list (liftListItem). If the item is the last one, Enter on the empty label exits the list and creates a paragraph below.
  3. Empty inside a nested list within a task item: Escapes to the parent task level by creating a new task item, rather than leaving a bare paragraph.

In the children-zone (blocks below the label):

  1. Empty paragraph: Inserts a new empty paragraph as a sibling INSIDE the same list item (the accumulate-on-Enter behavior). Use this to keep building children-zone content.
  2. Non-empty paragraph: Splits in place via splitBlock. Both halves stay in the same list item.
  • In the label: Falls through to ListKeymap’s default behavior (lift item).
  • In an empty children-zone paragraph: Lifts the empty paragraph as a top-level paragraph below the list, leaving the rest of the item intact. Use this to “escape” the children-zone with a single keystroke.

Inside a list, Tab/Shift-Tab are provided by the ListKeymap extension (sinkListItem / liftListItem). At the boundary between top-level blocks and lists, ListIndent (new in v0.7.0) handles the Tab/Shift-Tab cases that ListKeymap doesn’t cover.

ListItem automatically includes the ListKeymap extension via addExtensions():

ExtensionDescription
ListKeymapTab/Shift-Tab indentation and Backspace handling

A list item always wraps its content in block nodes:

{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "List item text" }
]
}
]
}

A list item with a nested list:

{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Parent item" }
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Nested item" }
]
}
]
}
]
}
]
}

@domternal/core - ListItem.ts