Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions docs/content/docs/features/custom-schemas/container-blocks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
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:

<Example name="custom-schema/container-block" />

## 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) => (
<ChildBlocksWrapper block={props.block} editor={props.editor} className="callout">
{/* Child blocks are rendered into the element you attach contentRef to. */}
<div className="callout-body" ref={props.contentRef} />
</ChildBlocksWrapper>
),
},
);
```

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`). |
| `collapseWhenEmptied` | `false` | Structural cleanup after children are removed: drops emptied children, and unwraps the container (replacing it with its remaining children, or removing it when none are left) once fewer than `min` non-empty children remain. Column lists set this to `true`. |

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). |
| `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,
collapseWhenEmptied: true,
}

// 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 `<input>` 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 `<div data-node-type="...">` 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.
6 changes: 6 additions & 0 deletions docs/content/docs/features/custom-schemas/custom-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ type BlockConfig = {
we set `content` to `"inline"`._
</Callout>

<Callout type="info">
_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)._
</Callout>

`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
Expand Down
15 changes: 15 additions & 0 deletions examples/06-custom-schema/09-container-block/.bnexample.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions examples/06-custom-schema/09-container-block/README.md
Original file line number Diff line number Diff line change
@@ -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 `<input>` 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)
14 changes: 14 additions & 0 deletions examples/06-custom-schema/09-container-block/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Container Block</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/06-custom-schema/09-container-block/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);
31 changes: 31 additions & 0 deletions examples/06-custom-schema/09-container-block/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
118 changes: 118 additions & 0 deletions examples/06-custom-schema/09-container-block/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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: <RiChatQuoteLine />,
});

type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];

export default function App() {
const [blocks, setBlocks] = useState<AppBlock[]>([]);

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 (
<div className={"wrapper"}>
<div>BlockNote Editor:</div>
<div className={"item"}>
<BlockNoteView
editor={editor}
slashMenu={false}
onChange={() => {
setBlocks(editor.document);
}}
>
<SuggestionMenuController
triggerCharacter={"/"}
getItems={async (query) => {
const defaultItems = getDefaultReactSlashMenuItems(editor);
const lastBasicBlockIndex = defaultItems.findLastIndex(
(item) => item.group === "Basic blocks",
);
defaultItems.splice(
lastBasicBlockIndex + 1,
0,
insertCallout(editor),
);
return filterSuggestionItems(defaultItems, query);
}}
/>
</BlockNoteView>
</div>
<div>Document JSON:</div>
<div className={"item bordered"}>
<pre>
<code>{JSON.stringify(blocks, null, 2)}</code>
</pre>
</div>
</div>
);
}
Loading