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 container config on BlockConfig. Setting container: { defaultBlocks: ["paragraph"] } (with content: "none") tells BlockNote to emit a ProseMirror node that holds nested blockContainer+ children — the same shape that columns use under the hood. The contained blocks live on block.children at runtime.
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.
- 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 `defaultBlocks` (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, NodeViewWrapper } 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 `container`// config — the block hosts arbitrary child blocks in its body, exposed at// runtime as `block.children`.export const createCallout = createReactBlockSpec( { type: "callout", propSchema: { flavor: { default: "tip", values: ["tip", "info", "warning", "success"], }, }, content: "none", container: { min: 1, defaultBlocks: ["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 }, }); }; return ( <NodeViewWrapper className={"callout"} data-node-type="callout" data-id={props.block.id} data-flavor={flavor.value} > <button className={"callout-icon-button"} type={"button"} contentEditable={false} onClick={cycleFlavor} title={`Click to cycle flavor (current: ${flavor.title})`} > <Icon size={20} /> </button> <div className={"callout-body"} ref={props.contentRef} /> </NodeViewWrapper> ); }, },);.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);}.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[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-body { flex-grow: 1; min-width: 0;}