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:
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: <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> );}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 new// `children` 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 <input> rendered inside the block.export const createCallout = createReactBlockSpec( { type: "callout", propSchema: { flavor: { default: "tip", values: ["tip", "info", "warning", "success"], }, title: { default: "", }, }, content: "none", children: { min: 1, default: [{ 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 ( <div className={"callout"}> <button className={"callout-icon-button"} type={"button"} contentEditable={false} onClick={cycleFlavor} aria-label={`Cycle callout flavor (current: ${flavor.title})`} title={`Click to cycle flavor (current: ${flavor.title})`} > <Icon size={20} /> </button> <div className={"callout-main"}> {/* 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. */} <div className={"callout-title-wrapper"} contentEditable={false}> <input className={"callout-title-input"} placeholder={"Add title"} defaultValue={props.block.props.title} onBlur={(event) => commitTitle(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } }} /> </div> <div className={"callout-body"} ref={props.contentRef} /> </div> </div> ); }, },);.wrapper { display: flex; flex-direction: column; height: 100%;}.item { border-radius: 0.5rem; flex: 1; overflow: hidden;}.item.bordered { border: 1px solid gray;}.item pre { border-radius: 0.5rem; height: 100%; overflow: auto; padding-block: 1rem; padding-inline: 54px; width: 100%; white-space: pre-wrap;}.callout { display: flex; align-items: flex-start; gap: 12px; flex-grow: 1; border-radius: 6px; padding: 12px 16px; border-left: 4px solid var(--callout-accent, #888); background-color: var(--callout-bg, #f3f4f6);}/* `tip` is the prop's default value, and BlockNote only writes a `data-*` attribute for props that differ from their default — so a callout left on `tip` carries no `data-flavor` at all. Style its absence alongside it. */.callout:not([data-flavor]),.callout[data-flavor="tip"] { --callout-accent: #d97706; --callout-bg: #fff7ed;}.callout[data-flavor="info"] { --callout-accent: #507aff; --callout-bg: #e6ebff;}.callout[data-flavor="warning"] { --callout-accent: #b91c1c; --callout-bg: #fef2f2;}.callout[data-flavor="success"] { --callout-accent: #16a34a; --callout-bg: #ecfdf5;}[data-color-scheme="dark"] .callout:not([data-flavor]),[data-color-scheme="dark"] .callout[data-flavor="tip"] { --callout-bg: #432e0e;}[data-color-scheme="dark"] .callout[data-flavor="info"] { --callout-bg: #1e2a5c;}[data-color-scheme="dark"] .callout[data-flavor="warning"] { --callout-bg: #4a1212;}[data-color-scheme="dark"] .callout[data-flavor="success"] { --callout-bg: #0d3b21;}.callout-icon-button { background: none; border: none; cursor: pointer; padding: 4px; color: var(--callout-accent, #888); display: flex; align-items: center; justify-content: center; margin-top: 2px;}.callout-icon-button:hover { opacity: 0.75;}.callout-main { flex-grow: 1; min-width: 0;}.callout-title-wrapper { margin-bottom: 4px;}.callout-title-input { width: 100%; border: none; background: none; outline: none; font-weight: 600; font-size: 1rem; color: inherit; padding: 0;}.callout-title-input::placeholder { color: var(--callout-accent, #888); opacity: 0.5;}.callout-body { flex-grow: 1; min-width: 0;}