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..6839c7544d
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,330 @@
+---
+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: 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.
+
+## Declaring a Container Block
+
+Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The only required field is `allow`, so the smallest container is:
+
+```typescript
+import { createReactBlockSpec } from "@blocknote/react";
+
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "none",
+ // Makes this a container: its body is other blocks.
+ children: { allow: "any" },
+ },
+ {
+ // Child blocks mount into the element you attach `contentRef` to.
+ render: (props) =>
,
+ },
+);
+```
+
+`children: { allow: "any" }` accepts any block, requires at least one, and never throws. 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. In fact, every regular block behaves as if it were declared with `children: { allow: "any", min: 0 }`; declaring `children` yourself is how you take control of the counts, the allowed types, and the rendering of that same field:
+
+```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 |
+| `content: "plain"` + `children` | its plain-text 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 `
` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike.
+
+
+ _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 callout block that can contain any other blocks. Its title is a regular `` backed by a string prop rather than document content, a pattern covered in [Editable fields that aren't document content](#editable-fields-that-arent-document-content):
+
+
+
+## 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, or a callout whose first line is real rich text rather than a plain ``. 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: { allow: "any", min: 0 },
+ },
+ {
+ render: (props) => (
+
+
+
+
+ ),
+ },
+);
+```
+
+This is purely additive: adding `children` to an existing block is one config line and no render changes. The block keeps its `Block` JSON shape, with `content` for its own content and `children` for its body, identical to any other nested block.
+
+`content: "plain"` combines with `children` the same way, for a head that is text but not *rich* text: no formatting marks and no inline nodes, like a code block's source. A file group whose header is a literal filename would use it:
+
+```typescript
+{
+ type: "fileGroup",
+ propSchema: {},
+ // The group's filename: plain, unformattable text.
+ content: "plain",
+ // The files it groups.
+ children: { allow: "any" },
+}
+```
+
+### The two regions
+
+Inside the `contentRef` element, BlockNote renders two sibling elements with stable attributes derived from the block type:
+
+- `[data-content-type=""]` holds the block's own (inline or plain) content.
+- `[data-children-of=""]` holds 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; }
+```
+
+
+ _ProseMirror imposes two limits here: 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
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `allow` | (required) | What may appear as a child: `"any"`, `"blocks"`, `"containers"`, or an array of container block types. See [Restricting children](#restricting-children). |
+| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. |
+| `default` | none | Partial blocks to create the container with when it's inserted without an explicit `children` array, and the source of `"refill"` top-ups. Validated against the rest of the config when the schema is created. See [Defaults and refilling](#defaults-and-refilling). |
+| `whenEmptied` | `"refill"` | What happens when fewer non-empty children remain than `min`: `"refill"` tops the container back up from `default`; `"unwrap"` replaces the container with its surviving children, or removes it entirely when none are left. Column lists use `"unwrap"` so emptied columns disappear and a one-column list dissolves. |
+| `boundary` | `"isolated"` | What crosses the container's edge: the caret, selections, or nothing. See [Boundaries](#boundaries). |
+
+`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. |
+
+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. |
+
+
+ _`whenEmptied` never destroys typed text. For a container with its own
+ content, neither value does anything while that content is non-empty._
+
+
+## Defaults and refilling
+
+`default` is an insertion template: a container inserted without an explicit `children` array is created with those blocks. Omit it and BlockNote fills the container with empty blocks its schema accepts.
+
+The same template drives `whenEmptied: "refill"`. When a refill container's non-empty children drop below `min`, say `k` remain, BlockNote appends `default[k]` through `default[min - 1]` at the end, falling back to empty blocks where `default` is absent or has no entry for a position. A checklist with `min: 2` and a two-entry `default` that loses its second item gets `default[1]` back, not a bare paragraph.
+
+## Boundaries
+
+`boundary` declares what may cross a container's edge:
+
+| Value | What crosses the edge |
+| --- | --- |
+| `"open"` | Everything: the caret, editing gestures, and text selections. A selection can start inside one child and end outside the container; `columnList` uses this so a selection can span columns. |
+| `"isolated"` (default) | The caret and editing gestures cross, but a text selection cannot span the edge. |
+| `"sealed"` | Nothing crosses implicitly: the caret doesn't wander in via arrow keys or Backspace, and from outside, the container selects and deletes as a single unit. |
+
+For editing gestures, `"open"` and `"isolated"` behave identically: the
+editor moves blocks across the edge implicitly, which is the right feel for
+flow regions. Backspace at the start of a container's first child moves that
+child out; Backspace at the start of the block *after* a container moves it
+inside; Delete mirrors both. Enter on an empty *last* child moves that block
+out below the container, the "double-Enter escapes" gesture every list
+editor has. An empty block mid-container never ejects; spacing inside a
+block is Shift+Enter's job. Columns and callouts want exactly this. The two
+values differ only in whether a *text selection* can reach across the edge.
+
+### Sealed containers
+
+A compartment, like a table cell, wants the opposite: whatever happens at
+its edges, content stays where it is. Declaring `boundary: "sealed"` gets
+that in one line:
+
+```typescript
+// A cell: holds any blocks, but nothing crosses its edge implicitly.
+children: { allow: "any", boundary: "sealed" },
+placement: "containerOnly",
+```
+
+Sealed means every *implicit* move across the edge is off, in both
+directions. Without it, each of these would need a hand-written keyboard
+handler:
+
+- Backspace at the start of the first child no longer moves it out; the
+ keystroke does nothing. Deeper in the container, Backspace behaves as
+ usual, merging into the previous sibling and un-indenting nested blocks.
+- Delete at the end of the last child no longer pulls the next block in.
+- Backspace after (or Delete before) a sealed container selects the
+ container instead of merging into it, so a second press deletes it as a
+ whole, deliberately.
+- Arrow keys from outside treat the container as a unit rather than
+ stepping the caret inside; clicking inside still places the caret, and
+ editing within the container is unrestricted.
+- Enter never moves the trailing block out: there is no double-Enter escape
+ from a sealed container.
+
+The setting is deliberately key-agnostic. It declares a fact about the
+boundary, not a keybinding, so any gesture that would implicitly move the
+caret or content across the edge consults it.
+
+Seals never bind the block manipulation API. `insertBlocks` and the rest
+ignore `boundary` entirely. An API call is an intentional crossing, so it
+can always place content inside a sealed container.
+
+## Restricting children
+
+`allow` takes one of four forms:
+
+```typescript
+allow: "any" | "blocks" | "containers" | string[]
+```
+
+- `"any"`: any regular block, plus any container placeable anywhere.
+- `"blocks"`: regular blocks only, no containers.
+- `"containers"`: any anywhere-placeable container, no regular blocks.
+- `string[]`: only the named container block types.
+
+The wildcard forms (`"any"`, `"containers"`) exclude `placement: "containerOnly"` types: a `column` never shows up inside your callout just because the callout accepts "any" block. A containerOnly type appears only where a parent names it in an array.
+
+The array form is exact because container blocks are each their own ProseMirror node type. Every regular block, whether paragraph, heading, or code block, is the *same* ProseMirror node internally, so "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error; per-type filtering of regular blocks is not yet supported, and the array is where it will land later with no API change.
+
+This is exactly how the multi-column blocks are defined:
+
+```typescript
+// The outer container: only columns, at least two of them;
+// unwraps when it drops to one, and selections span its columns.
+children: {
+ allow: ["column"],
+ min: 2,
+ whenEmptied: "unwrap",
+ boundary: "open",
+}
+
+// The column: holds any blocks, but only lives inside a columnList.
+children: { allow: "any" },
+placement: "containerOnly",
+```
+
+## Inserting into a container
+
+[`editor.insertBlocks`](/docs/reference/editor/manipulating-content#inserting-blocks) 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: an empty array, or a wildcard form when no anywhere-placeable container exists;
+- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not yet supported](#restricting-children));
+- `content: "table"` combined with `children`;
+- a `placement: "containerOnly"` block that no container's `allow` array names, or `placement: "containerOnly"` on a regular block;
+- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks 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=""]`, which is what makes HTML produced by BlockNote round-trip.
+
+To recognize *foreign* HTML, add `implementation.parse`, which returns the block's props, or `undefined` to decline:
+
+```typescript
+{
+ render: (props) => ,
+ 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 `
…
…
` 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; its `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 `
` 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 ``.
+- **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. A name, a URL, or a label doesn't need rich text formatting, comments, or multiplayer cursors: store it as a string prop and render a regular `` inside the block, in a `contentEditable={false}` wrapper, committing the value with `editor.updateBlock`. The callout demo on this page does exactly that for its title.
+
+Use a container's own `content: "inline"` when the field *is* prose, and a string prop when it's data.
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..2e2479acf9 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -72,6 +72,12 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
+
+ _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)._
+
+
`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/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx
index 1a9c97c222..873a186f17 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before"
+ placement: "before" | "after" | "start" | "end" = "before"
): void
```
-Inserts new blocks relative to an existing block.
+Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).
```typescript
// Insert a paragraph before an existing block
@@ -164,6 +164,13 @@ editor.insertBlocks(
"existing-block-id",
"after",
);
+
+// Insert a paragraph as the last child of a container block
+editor.insertBlocks(
+ [{ type: "paragraph", content: "Nested paragraph" }],
+ "container-block-id",
+ "end",
+);
```
### Updating Blocks
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..070dd71987
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -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 wrapping a paragraph followed by a code block.
+
+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 `` 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.
+- 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)
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..29778f9255
--- /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": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite 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": "^8.0.0"
+ }
+}
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..3d6cf55ba1
--- /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 `children.default` (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..b150cead50
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx
@@ -0,0 +1,104 @@
+import { 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 `children`
+// config: the block hosts arbitrary child blocks in its body, exposed at
+// runtime as `block.children`.
+//
+// The callout's title shows a related 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",
+ // `children: { allow: "any" }` is the entire container declaration: any
+ // block is allowed, at least one is required, and BlockNote fills the
+ // callout with an empty paragraph when it's created. `min` / `max` /
+ // `default` / `whenEmptied` / `boundary` tune this.
+ children: { allow: "any" },
+ },
+ {
+ 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 (
+
+
+
+ {/* The title lives in a string prop, not in document content,
+ and is edited via a plain input. `contentEditable={false}`
+ keeps ProseMirror from treating typing here as document
+ input. */}
+