diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx
new file mode 100644
index 0000000000..2fa4208d65
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,120 @@
+---
+title: Container Blocks
+description: Learn how to create custom blocks that hold other blocks as their body
+---
+
+# 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. 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 `childBlocks` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The block must declare `content: "none"` — its body is made of child blocks, not inline content:
+
+```typescript
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ // The callout has no inline content of its own; it defers to its children.
+ content: "none",
+ // Marks the block as a container of other blocks.
+ childBlocks: {
+ // At least one child block is required.
+ min: 1,
+ // Seeded when the block is inserted without explicit children.
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: (props) => (
+
+ {/* Child blocks are rendered into the element you attach contentRef to. */}
+
+
+ ),
+ },
+);
+```
+
+At runtime, the contained blocks live on `block.children` — the same field used for indented (nested) blocks:
+
+```json
+{
+ "id": "callout-1",
+ "type": "callout",
+ "props": { "flavor": "tip" },
+ "content": undefined,
+ "children": [
+ {
+ "id": "para-1",
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "Hello", "styles": {} }],
+ "children": []
+ }
+ ]
+}
+```
+
+### `ChildBlocksWrapper` (React)
+
+Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Your `render` should return a `ChildBlocksWrapper` (exported from `@blocknote/react`) as the root element: it automatically applies the attributes BlockNote relies on for HTML parsing and UI positioning (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). Any other props (`className`, event handlers) are passed through.
+
+For vanilla JS blocks (`createBlockSpec`), return a DOM element with `contentDOM` pointing to where children mount. BlockNote fills in the missing `data-*` attributes when serializing to HTML, but it's good practice to set `data-node-type` and `data-id` yourself so UI features (side menu positioning, drag & drop) work on the live editor DOM.
+
+## `childBlocks` options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `allowedBlocks` | any block | Block types allowed as direct children. Container types are enforced exactly by the schema; regular block types collapse to "any regular block" (they all share one node type internally). |
+| `min` / `max` | `1` / unbounded | How many children are allowed. Enforced by the editor schema. |
+| `defaultChildren` | — | Partial blocks seeded when the container is inserted without children. Validated against `allowedBlocks`/`min`/`max` when the schema is created. |
+| `topLevel` | `true` | Whether the block can appear anywhere a regular block goes. Set `false` for blocks that only make sense inside a specific parent (like a `column` inside a `columnList`). |
+| `repair` | — | Structural cleanup after children are removed: `removeEmptyChildren` drops emptied children, and `belowMin` (`"unwrap"` / `"remove"` / `"fill"`) decides what happens when fewer than `min` non-empty children remain. Column lists use `{ removeEmptyChildren: true, belowMin: "unwrap" }`. |
+
+Behavioral options live in the block implementation's `meta` instead, since they don't affect the document schema:
+
+| Meta option | Default | Description |
+| --- | --- | --- |
+| `exitOnEnter` | `true` | Pressing Enter on an empty last child moves it out of the container, list-style. Disable to keep the cursor inside (columns do this). |
+| `childLayout` | `"vertical"` | Set `"horizontal"` for side-by-side children (like columns) — drives side menu positioning and edge-drop behavior. |
+| `draggable` | `true` | Whether the container itself gets a side menu drag handle. |
+
+### Restricting children: a columnList-style pair
+
+`allowedBlocks` + `topLevel: false` let you build tightly-coupled structures. This is exactly how the multi-column blocks are defined:
+
+```typescript
+// The outer container: only accepts columns, at least two of them.
+childBlocks: {
+ allowedBlocks: ["column"],
+ min: 2,
+ repair: { removeEmptyChildren: true, belowMin: "unwrap" },
+}
+
+// The column: holds any blocks, but can only live inside a columnList.
+childBlocks: { topLevel: false }
+```
+
+The same pattern works for table-like structures (a "grid" of "cells"), FAQ lists, and so on. Configurations are validated when the schema is created — unknown `allowedBlocks` entries, impossible `defaultChildren`, and container cycles that could never be auto-filled all fail up front with a clear error.
+
+## Editable fields that aren't document content
+
+A container can only have one "hole" for child blocks and no inline content of its own. If your block needs an extra editable field — like the callout's title — store it as a **string prop** and render a regular `` inside the block (in a `contentEditable={false}` wrapper), committing the value with `editor.updateBlock`. See the demo above for a full implementation.
+
+This is the right tool when the field doesn't need rich text formatting, comments, or multiplayer cursors — it's plain data on the block, not document content.
+
+## Interop behavior
+
+- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly.
+- **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.
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index 60bacafe68..a8ae04da4e 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -68,6 +68,12 @@ type BlockConfig = {
we set `content` to `"inline"`._
+
+ _Blocks with `content: "none"` can instead hold **other blocks** as their
+ body by declaring the `childBlocks` option — see [Container
+ Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
`propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.
```typescript
diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/.bnexample.json
@@ -0,0 +1,15 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "nickthesick",
+ "tags": [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu"
+ ],
+ "dependencies": {
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md
new file mode 100644
index 0000000000..4a285c69b8
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -0,0 +1,21 @@
+# Container Block
+
+In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.
+
+The block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.
+
+The callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`.
+
+We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.
+
+**Try it out:**
+
+- Press the "/" key inside the callout's body and add a code block, heading, or list — anything goes.
+- Type a title into the title field — it's stored on `block.props.title`, not as document content.
+- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`.
+- Insert a new callout via the Slash Menu (search "callout").
+
+**Relevant Docs:**
+
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html
new file mode 100644
index 0000000000..19321f77b5
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Container Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json
new file mode 100644
index 0000000000..d92c915975
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-container-block",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vp dev",
+ "dev": "vp dev",
+ "build:prod": "tsc && vp build",
+ "preview": "vp preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite-plus": "^0.1.24"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx
new file mode 100644
index 0000000000..3f3255bbba
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/App.tsx
@@ -0,0 +1,118 @@
+import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import {
+ filterSuggestionItems,
+ insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import { useEffect, useState } from "react";
+import { RiChatQuoteLine } from "react-icons/ri";
+
+import { createCallout } from "./Callout";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Callout container block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: createCallout(),
+ },
+});
+
+// Slash menu item to insert a Callout. Because Callout is a container block,
+// inserting one with no children causes BlockNote to seed it with the block's
+// configured `defaultChildren` (a single paragraph here).
+const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({
+ title: "Callout",
+ subtext: "Container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "callout",
+ }),
+ aliases: ["callout", "container", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+});
+
+type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];
+
+export default function App() {
+ const [blocks, setBlocks] = useState([]);
+
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome — this demo shows the new container block kind.",
+ },
+ {
+ type: "callout",
+ props: { flavor: "tip" },
+ children: [
+ {
+ type: "paragraph",
+ content: "Callouts can hold any block as their body.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Try pressing '/' inside this callout to add a heading or code block.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Callout.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ useEffect(() => setBlocks(editor.document), [editor]);
+
+ return (
+
+ );
+}
diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx
new file mode 100644
index 0000000000..35eea18d72
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx
@@ -0,0 +1,109 @@
+import { ChildBlocksWrapper, createReactBlockSpec } from "@blocknote/react";
+import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md";
+
+import "./styles.css";
+
+// The flavors of callout the user can switch between.
+export const calloutTypes = [
+ { value: "tip", title: "Tip", icon: MdLightbulb },
+ { value: "info", title: "Info", icon: MdInfo },
+ { value: "warning", title: "Warning", icon: MdWarning },
+ { value: "success", title: "Success", icon: MdCheckCircle },
+] as const;
+
+// The Callout block. Declared with `content: "none"` plus the new
+// `childBlocks` config — the block hosts arbitrary child blocks in its body,
+// exposed at runtime as `block.children`.
+//
+// The callout's title demonstrates the complementary "string prop slot"
+// pattern: content that shouldn't be part of the rich-text document (no
+// formatting, comments, or multiplayer cursors needed) can live in a plain
+// string prop, edited through a regular rendered inside the block.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ title: {
+ default: "",
+ },
+ },
+ content: "none",
+ childBlocks: {
+ min: 1,
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: (props) => {
+ const flavor =
+ calloutTypes.find((c) => c.value === props.block.props.flavor) ??
+ calloutTypes[0];
+ const Icon = flavor.icon;
+
+ const cycleFlavor = () => {
+ const idx = calloutTypes.findIndex(
+ (c) => c.value === props.block.props.flavor,
+ );
+ const next = calloutTypes[(idx + 1) % calloutTypes.length];
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { flavor: next.value },
+ });
+ };
+
+ const commitTitle = (title: string) => {
+ if (title !== props.block.props.title) {
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { title },
+ });
+ }
+ };
+
+ return (
+ // `ChildBlocksWrapper` is the root element for container blocks — it
+ // automatically applies the `data-node-type` / `data-id` / prop
+ // attributes BlockNote needs for HTML parsing and UI positioning.
+
+
+
+ {/* The title lives in a string prop, not in document content —
+ it's edited via a plain input. `contentEditable={false}` keeps
+ ProseMirror from treating typing here as document input. */}
+