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
280 changes: 280 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,280 @@
---
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, 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:

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

## Declaring a Container Block

Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). Everything about it is optional, so the smallest container is:

```typescript
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:

```json
{
"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:

| block | `contentRef` element holds |
| --- | --- |
| `content: "inline"`, no `children` | its inline content |
| `content: "none"` + `children` | its child blocks |
| `content: "inline"` + `children` | its 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.

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

## 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`:

```typescript
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:

```css
.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; }
```

<Callout type="warn">
**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.
</Callout>

## `children` options

| Option | Default | Description |
| --- | --- | --- |
| `allow` | any block | What may appear as a child. See [Restricting children](#restricting-children). |
| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. |
| `sequence` | — | An *ordered* body instead of a uniform one. Mutually exclusive with `allow`/`min`/`max`. See [Ordered children](#ordered-children). |
| `default` | — | Partial 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. |
| `unwrapWhenEmptied` | `false` | As 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. |
| `exitOnEnter` | `true` | Pressing 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:

| Option | Default | Description |
| --- | --- | --- |
| `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 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. |

<Callout type="warn">
_`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._
</Callout>

## Restricting children

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

```typescript
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:

```typescript
// 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`:

```typescript
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 }`:

```typescript
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:

```typescript
// 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:

```typescript
{
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.

<Callout type="warn">
**`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.
</Callout>

## 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.
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 @@ -72,6 +72,12 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
</Callout>

<Callout type="info">
_Any block can also hold **other blocks** as its body by declaring the
`children` option — with or without inline content of its own. 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"
}
}
22 changes: 22 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,22 @@
# 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 declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.

The callout's **title** demonstrates the complementary "string prop slot" pattern: a field 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`. A field that _is_ prose belongs in the block's own `content: "inline"` instead.

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:**

- [Container Blocks](/docs/features/custom-schemas/container-blocks)
- [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"
}
}
Loading
Loading