BlockNote DocsFeaturesCustom SchemasContainer Blocks

Container Blocks

A container block is a custom block that holds other blocks as its body — like a Notion-style callout wrapping a paragraph and a code block, a toggle with a title and a body, or a multi-column layout. BlockNote's built-in multi-column blocks (columnList / column) are implemented with this same mechanism.

Take a look at the demo below, in which we add a custom callout block that can contain any other blocks:

Declaring a Container Block

Add the children option to your block config (created with createBlockSpec or createReactBlockSpec). Everything about it is optional, so the smallest container is:

const createCallout = createReactBlockSpec(
  {
    type: "callout",
    propSchema: {},
    content: "none",
    // Makes this a container: its body is other blocks.
    children: {},
  },
  {
    // Child blocks mount into the element you attach `contentRef` to.
    render: (props) => <div className="callout" ref={props.contentRef} />,
  },
);

children: {} accepts any block, requires at least one, and can never throw: when a container is created without children, BlockNote fills it with whatever its schema requires.

At runtime the contained blocks live on block.children — the same field used for indented (nested) blocks:

{
  "id": "callout-1",
  "type": "callout",
  "props": {},
  "children": [
    {
      "id": "para-1",
      "type": "paragraph",
      "content": [{ "type": "text", "text": "Hello", "styles": {} }],
      "children": []
    }
  ]
}

Where children render

There is only one placement mechanism, and it is the one you already use for inline content. contentRef (React) / contentDOM (vanilla) marks the block's editable region; what goes in that region depends on the block:

blockcontentRef element holds
content: "inline", no childrenits inline content
content: "none" + childrenits child blocks
content: "inline" + childrenits inline content, then its child blocks

A content: "none" block without children is the only kind with nothing to place, and it's the only kind that isn't offered a contentRef at all.

Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Whatever element your render returns is the block's element, and BlockNote stamps the attributes it relies on for parsing and UI positioning onto it: data-node-type, data-id, and each non-default prop as a data-* attribute. You write a plain <div className="callout"> and data-flavor="info" lands on it, in the live editor and in serialized HTML alike.

Because the framework wrappers React puts above your element carry display: contents, they contribute no box: your element lays out exactly as if it were the block's root. Selection is mirrored onto it as a data-selected attribute, so [data-selected] is what you style for the selected state.

Containers with their own content

A container can have inline content of its own as well as children — a toggle's title with its body beneath it, a card header, a callout whose first line is real rich text rather than a plain <input>. Combine content: "inline" with children, and place both with the same single contentRef:

const createToggle = createReactBlockSpec(
  {
    type: "toggle",
    propSchema: {},
    // The toggle's own title...
    content: "inline",
    // ...and its body.
    children: { min: 0 },
  },
  {
    render: (props) => (
      <div className="toggle">
        <span className="chevron" contentEditable={false} />
        <div className="toggle-main" ref={props.contentRef} />
      </div>
    ),
  },
);

This is purely additive: adding children to an existing block is one config line and zero render changes. The block keeps its Block JSON shape — content for its own content, children for its body — identical to any other nested block.

The two regions

Inside the contentRef element, BlockNote renders two sibling elements with stable attributes derived from the block type:

  • [data-content-type="<type>"] — the block's own inline content.
  • [data-children-of="<type>"] — its child blocks.

You never place these yourself; you style them. The host element between them carries display: contents, so a grid on your own root reaches them directly:

.toggle      { display: grid; grid-template-columns: auto 1fr; }
.toggle-main { display: contents; }
.chevron                     { grid-column: 1; grid-row: 1; }
[data-content-type="toggle"] { grid-column: 2; grid-row: 1; }
[data-children-of="toggle"]  { grid-column: 2; grid-row: 2; }

Two limits, both imposed by ProseMirror: reading order is always content-then-children, and your own markup cannot be interleaved between the two regions or wrap only one of them. A grid (or order) can reorder them visually; the DOM order is fixed.

children options

OptionDefaultDescription
allowany blockWhat may appear as a child. See Restricting children.
min / max1 / unboundedHow many children are allowed. Compiled into the editor schema.
sequenceAn ordered body instead of a uniform one. Mutually exclusive with allow/min/max. See Ordered children.
defaultPartial blocks to create the container with when it's inserted without an explicit children array. Validated against the rest of the config when the schema is created. Omit it and BlockNote fills the container with whatever its schema requires.
unwrapWhenEmptiedfalseAs children are emptied out, drop the emptied ones and — once fewer non-empty children remain than min — replace the container with its survivors, or remove it entirely when none are left. Column lists use this so emptied columns disappear and a one-column list unwraps.
exitOnEntertruePressing Enter on an empty block that is the last child moves that block out of the container, list-style. Disable it to keep the cursor inside (columns do this).

placement sits next to children on the block config rather than inside it, because it's a fact about this block rather than about its children:

OptionDefaultDescription
placement"anywhere""containerOnly" restricts the block to containers that name it in children.allow.containers — like a column, which only makes sense inside a columnList. Only valid on container blocks.

Purely behavioral options that apply to every block kind stay in the block implementation's meta:

Meta optionDefaultDescription
draggabletrueWhether the block gets a side menu drag handle. A block that opts out is skipped when looking for a handle, so the handle falls through to the nearest draggable ancestor.

unwrapWhenEmptied never destroys typed text: for a container with its own content, it does nothing at all while that content is non-empty. And without it, a container that drops below min isn't unwrapped — ProseMirror refills it with an empty child instead.

Restricting children

allow has two fields, because the document schema can make exactly two distinctions:

allow?: {
  // Whether regular (non-container) blocks are allowed.
  blocks?: boolean;
  // Which container-block types are allowed: `true`, `false`, or a list.
  containers?: boolean | string[];
}

Container blocks are their own ProseMirror node type, so naming them is exact. Every regular block — paragraph, heading, code block — is the same ProseMirror node internally, so "only headings" is not something the schema can enforce. Rather than offer an option that silently does nothing, allow.blocks is a boolean. Naming a regular block type in allow.containers is a hard error that says so.

This is exactly how the multi-column blocks are defined:

// The outer container: only columns, at least two of them.
children: {
  allow: { blocks: false, containers: ["column"] },
  min: 2,
  unwrapWhenEmptied: true,
  exitOnEnter: false,
}

// The column: holds any blocks, but only lives inside a columnList.
children: { exitOnEnter: false },
placement: "containerOnly",

Ordered children

sequence replaces allow/min/max with a list of positions. Each slot holds exactly one child unless it declares a count:

children: {
  sequence: [
    { allow: { blocks: false, containers: ["cardHeader"] } },
    { allow: { blocks: false, containers: ["cardBody"] } },
  ],
}

That compiles to the ProseMirror content expression cardHeader cardBody, so the order is enforced by the document model itself: a card built as [body, header] is rejected before it can reach the document, and a card inserted with no children auto-fills one of each.

count takes a number for an exact count, or { min, max }:

children: {
  sequence: [
    // Exactly one header.
    { allow: { blocks: false, containers: ["cardHeader"] } },
    // Then one or more blocks.
    { count: { min: 1 } },
  ],
}

The uniform form is exactly sugar for a one-slot sequence: { allow, min, max } is { sequence: [{ allow, count: { min, max } }] }.

Inserting into a container

editor.insertBlocks takes two nested placements alongside the sibling ones:

// Siblings of the reference block:
editor.insertBlocks([{ type: "paragraph" }], calloutId, "before");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "after");

// Nested inside it, as its first or last child:
editor.insertBlocks([{ type: "paragraph" }], calloutId, "start");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "end");

The nested placements are what addresses a container with no children to point at — a min: 0 container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your children config that decides.

Validation

Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types and impossible default children, this catches:

  • an allow that permits nothing, or allow.containers naming a regular block type;
  • an empty sequence, and content: "table" combined with children;
  • a placement: "containerOnly" block that no container accepts, or placement on a non-container;
  • container cycles — a container that (transitively) requires a child that requires it back could never be created. Slots that allow regular blocks break the cycle, since they're always satisfiable.

Parsing HTML into a container

Containers go through the same parsing path as regular blocks. The default rule matches the marker BlockNote puts on the block's root, [data-node-type="<type>"], which is what makes HTML produced by BlockNote round-trip.

To recognize foreign HTML, add implementation.parse — it returns the block's props, or undefined to decline:

{
  render: (props) => <div className="card" ref={props.contentRef} />,
  parse: (el) =>
    el.classList.contains("card")
      ? { tone: el.getAttribute("data-tone") ?? undefined }
      : undefined,
}

With no parseContent, ProseMirror parses the element's children with the normal block rules, so <div class="card"><p>…</p><h1>…</h1></div> becomes a card with a paragraph and a heading. Supply parseContent only if you need to build the body yourself; inline nodes it returns become paragraph children, except a leading inline run in a container that has its own content, which becomes that content.

runsBefore orders your parse rule against other blocks'. On a container it may only name other containers: container nodes register in a priority band below regular blocks, so a container can never be ordered ahead of one — a container's tag: "*" rule is always considered after every regular block's. Naming a regular block there is an error rather than a silent no-op.

allow does not filter what a user pastes. Pasted HTML is parsed with blockGroup as its top node, and ProseMirror's fitting algorithm places content your container's expression rejects after the container rather than dropping it. allow constrains the document model, not the parser.

Interop behavior

  • HTML: containers serialize to a <div data-node-type="..."> with their children nested inside and non-default props as data-* attributes, and parse back losslessly. A container with its own content serializes its two regions as [data-content-type] and [data-children-of] elements.
  • External HTML (blocksToHTMLLossy, copy to another app) is intentionally semantic and lossy. Override toExternalHTML and return a childrenDOM to say where children belong in your own markup — this is how toggles export as <details>.
  • Markdown: containers are flattened — their children are exported in order, and Markdown import never produces containers.
  • Exporters (@blocknote/xl-docx-exporter, xl-pdf-exporter, xl-odt-exporter, xl-email-exporter): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error.

Editable fields that aren't document content

Not every editable field belongs in the document. If a field doesn't need rich text formatting, comments, or multiplayer cursors — a name, a URL, a label — store it as a string prop and render a regular <input> inside the block, in a contentEditable={false} wrapper, committing the value with editor.updateBlock. The callout demo at the top of this page does exactly that for its title.

Reach for a container's own content: "inline" when the field is prose, and for a string prop when it's data.