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, or a multi-column layout.
Declaring a Container Block
Add the children option to your block config (created with createBlockSpec or createReactBlockSpec). The only required field is allow, so the smallest container is:
import { createReactBlockSpec } from "@blocknote/react";
const createPanel = createReactBlockSpec(
{
type: "panel",
propSchema: {},
content: "none",
// Makes this a container: its body is other blocks.
children: { allow: "blocks" },
},
{
// Child blocks mount into the element you attach `contentRef` to.
render: (props) => <div className="panel" ref={props.contentRef} />,
},
);children: { allow: "blocks" } accepts any block and requires at least one. When a container is created without children, BlockNote fills it with empty blocks its schema accepts.
A pure container declares content: "none": its body is its children. Combining children with content: "inline" instead makes a compartment: the block keeps its own content as a title, with the children as its body. Combining children with any other content is a schema-creation error. Fields that aren't prose — a flavor, a label, a toggle state — belong in the prop schema instead of document content.
At runtime the contained blocks live on block.children, the same field used for indented (nested) blocks. In fact, every regular block behaves as if it were declared with children: { allow: "blocks", min: 0 }; declaring children yourself is how you take control of the counts, the allowed types, and the rendering of that same field:
{
"id": "panel-1",
"type": "panel",
"props": {},
"children": [
{
"id": "para-1",
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello", "styles": {} }],
"children": []
}
]
}Where children render
Vanilla render marks the block's editable region with contentDOM (React: contentRef). renderFrame adds a second knob for blocks that draw a box around children: whatever element it returns as slot becomes the mount point.
| block | children mount |
|---|---|
content: "inline", no children | contentDOM holds its inline content |
content: "none" + children (pure container) | the frame's slot holds its child blocks |
content: "inline" + children (compartment) | contentDOM holds the title; the frame's slot holds the rendered title followed by the 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.
A pure container owns its entire outer DOM. BlockNote doesn't wrap it in the usual block element: whatever element your renderFrame returns as dom 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). A compartment keeps the standard block wrapper; its frame lives inside it, surrounding the title and the body together.
The framework wrappers React puts above your element carry display: contents, so they contribute no box and 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.
The demo below puts this together: a panel block that can contain any other blocks, drawing its box in renderFrame with a flavor switcher in the frame chrome:
Compartments
A compartment is a block with both a title and a body: content: "inline" plus children. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — and the body is child blocks that belong to it. render draws the title row, renderFrame draws the box around the title and the body together:
const createAlert = createBlockSpec(
{
type: "alert",
propSchema: {
flavor: { default: "info", values: ["info", "warning", "success"] },
},
content: "inline",
children: { allow: "blocks" },
},
{
// The title row: the title mounts into `contentDOM`.
render: () => {
const dom = document.createElement("div");
const contentDOM = document.createElement("span");
dom.append(contentDOM);
return { dom, contentDOM };
},
// The box: the title row and the body render into `slot` together.
// Flavor styling lives here, since the frame rebuilds when props change.
renderFrame: (block) => {
const dom = document.createElement("div");
dom.dataset.flavor = block.props.flavor;
const slot = document.createElement("div");
dom.append(slot);
return { dom, slot };
},
},
);Editing gestures treat the title and the body as one unit: Enter at the end of the title starts the body, Enter on an empty last body block leaves it, Backspace at the start of the first body block merges back into the title, and Shift-Tab stops at the body's edge instead of lifting the block out of it. An alert without a title needs no title row — it is just a pure container drawing its box in renderFrame. The demos below show both side by side in vanilla JS, followed by the same compartment idea in React — a callout whose title is real rich text:
In React (createReactBlockSpec), render and renderFrame are components receiving { block, editor, contentRef }. Attach the slot with ref={contentRef}. A pure container's frame renders live, so prop changes re-render it in place. A compartment's frame is installed as a static snapshot instead — no React context, no updates, no interactive chrome — so read everything the box needs from block and editor, and return null to decline the frame. Exports draw the frame the same way.
children options
| Option | Default | Description |
|---|---|---|
allow | (required) | What may appear as a child: "blocks", or an array of container block types. See Restricting children. |
min | 1 | How few children the container may hold. Compiled into the editor schema. |
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:
| Option | Default | Description |
|---|---|---|
placement | "anywhere" | "containerOnly" restricts the block to containers that name it in their children.allow array, like a column, which only makes sense inside a columnList. It also requires the block to be a container itself. "anywhere" is valid on any block; on a regular block it simply restates the default. |
column and columnList, introduced in Multi-Column Layouts, are themselves container blocks defined with this API — Restricting children shows their exact config.
Purely behavioral options that apply to every block kind stay in the block implementation's meta:
| Meta option | Default | Description |
|---|---|---|
draggable | true | Whether 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. |
When a container's non-empty children drop below min, BlockNote repairs it: a container that can live anywhere is replaced by its surviving children (or removed when none are left), so emptied columns disappear and a one-column list dissolves. A containerOnly block can never stand outside its parent, so it is padded back up to min with empty blocks instead.
Repair never destroys typed text: only empty children are dropped.
Restricting children
allow takes one of two forms:
allow: "blocks" | string[]"blocks": any regular block, plus any container placeable anywhere.string[]: only the named container block types.
The "blocks" form excludes placement: "containerOnly" types: a column never shows up inside your panel just because the panel accepts "blocks". A containerOnly type appears only where a parent names it in an array.
Only container block types can be named in the array. Naming a regular block type is a startup error, since regular blocks share one node type and can only be allowed as a whole — see Validation.
This is exactly how the multi-column blocks are defined:
// The outer container: only columns, at least two of them;
// dissolves when it drops to one.
children: {
allow: ["column"],
min: 2,
}
// The column: holds any blocks, but only lives inside a columnList.
children: { allow: "blocks" },
placement: "containerOnly",Inserting into a container
editor.insertBlocks takes two nested placements alongside the sibling ones:
// Siblings of the reference block:
editor.insertBlocks([{ type: "paragraph" }], panelId, "before");
editor.insertBlocks([{ type: "paragraph" }], panelId, "after");
// Nested inside it, as its first or last child:
editor.insertBlocks([{ type: "paragraph" }], panelId, "first-child");
editor.insertBlocks([{ type: "paragraph" }], panelId, "last-child");first-child and last-child insert inside the referenced block, before or after its existing children. They're also the only way into a container that is currently empty: before and after need an existing child to anchor to.
Validation
Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types, this catches:
- an
allowthat permits nothing: an empty array; - an
allowarray naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is not supported); childrencombined with anycontentother than"none"or"inline";- a
placement: "containerOnly"block that no container'sallowarray names, orplacement: "containerOnly"on a regular block; - container cycles: a container that (transitively) requires a child that requires it back could never be created. An
allowthat permits regular blocks breaks the cycle, since they're always satisfiable.
Documents are checked too. initialContent that doesn't fit the schema throws when the editor is created, rather than loading in a broken state. This matters when you change a children config on a schema whose documents are already saved somewhere: a stored document that no longer fits, say a columnList left with a single column, now fails at load. Migrate those documents before shipping the change.
Parsing HTML into a container
A container's parse callback and parseContent work as described for custom blocks. The default parse rule differs, though: containers match [data-node-type="<type>"], not the data-content-type attribute regular blocks use.
What's specific to a container is its body. By default BlockNote 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 when you need to build the body yourself.
allow does not filter what a user pastes. Content your container rejects is
placed after the container rather than dropped. allow constrains the
document model, not the parser.
Interop behavior
Containers serialize to a <div> with their children nested inside, and round-trip losslessly. For lossy targets you place the children yourself: return a childrenDOM from toExternalHTML (this is how toggles export as <details>), and give container blocks an explicit mapping in the DOCX, ODT, email, Typst, and PDF exporters, which throw on a missing one. That mapping receives the container's rendered children as its last argument and decides where they go — the exporters do not append them after the container's own output. Markdown flattens containers, exporting their children in order.