Container Block
In this example, we create a custom Panel 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: { allow: "blocks" } makes it a container: its child blocks mount into the frame's slot (attached with ref={contentRef}), and live on block.children at runtime. A pure container like this draws its box in renderFrame alone, which re-renders live when props change — click the icon to cycle the panel's flavor and watch the box follow without rebuilding the body.
We also wire up a Slash Menu item to insert the panel, 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 panel's body and add a code block, heading, or list.
- Click the panel's icon to cycle its flavor. The box re-renders in place; the children are untouched.
- Watch the JSON panel on the right update as you edit; the panel's children appear in
block.children. - Insert a new panel via the Slash Menu (search "panel").
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 { createPanel } from "./Panel";import "./styles.css";// Schema with the default blocks plus our custom Panel container block.const schema = BlockNoteSchema.create().extend({ blockSpecs: { ...defaultBlockSpecs, panel: createPanel(), },});// Slash menu item to insert a Panel. Inserting one with no children fills// it with an empty paragraph, as `min` defaults to 1.const insertPanel = (editor: typeof schema.BlockNoteEditor) => ({ title: "Panel", subtext: "Container block that wraps other blocks", onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: "panel", }), aliases: ["panel", "container", "callout", "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: "panel", props: { flavor: "tip" }, children: [ { type: "paragraph", content: "Panels can hold any block as their body.", }, { type: "paragraph", content: "Try pressing '/' inside this panel to add a heading or code block.", }, ], }, { type: "paragraph", content: "Press '/' anywhere to insert a new Panel.", }, { 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, insertPanel(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 panel the user can switch between.export const panelFlavors = [ { 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 Panel block. Declared with `content: "none"` plus the `children`// config: the block hosts arbitrary child blocks in its body, exposed at// runtime as `block.children`.//// A pure container draws its box in `renderFrame` alone: the children mount// straight into the frame's `slot`. The frame renders live inside the React// node view, so prop changes (like the flavor below) re-render the box in// place without rebuilding the children.export const createPanel = createReactBlockSpec( { type: "panel", propSchema: { flavor: { default: "tip", values: ["tip", "info", "warning", "success"], }, }, content: "none", // `children: { allow: "blocks" }` is the entire container declaration: any // block is allowed, at least one is required, and BlockNote fills the // panel with an empty paragraph when it's created. `min` tunes this. children: { allow: "blocks" }, }, { renderFrame: (props) => { const flavor = panelFlavors.find((f) => f.value === props.block.props.flavor) ?? panelFlavors[0]; const Icon = flavor.icon; const cycleFlavor = () => { const idx = panelFlavors.findIndex( (f) => f.value === props.block.props.flavor, ); const next = panelFlavors[(idx + 1) % panelFlavors.length]; props.editor.updateBlock(props.block, { type: "panel", props: { flavor: next.value }, }); }; return ( <div className={"panel"} data-flavor={props.block.props.flavor}> <button className={"panel-icon-button"} type={"button"} contentEditable={false} onClick={cycleFlavor} aria-label={`Cycle panel flavor (current: ${flavor.title})`} title={`Click to cycle flavor (current: ${flavor.title})`} > <Icon size={20} /> </button> <div className={"panel-body"} ref={props.contentRef} /> </div> ); }, },);.wrapper { display: flex; flex-direction: column; height: 100%;}.item { border-radius: 0.5rem; flex: 1; overflow: auto;}.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;}.panel { display: flex; align-items: flex-start; gap: 12px; flex-grow: 1; border-radius: 6px; padding: 12px 16px; border-left: 4px solid var(--panel-accent, #888); background-color: var(--panel-bg, #f3f4f6);}.panel[data-flavor="tip"] { --panel-accent: #d97706; --panel-bg: #fff7ed;}.panel[data-flavor="info"] { --panel-accent: #507aff; --panel-bg: #e6ebff;}.panel[data-flavor="warning"] { --panel-accent: #b91c1c; --panel-bg: #fef2f2;}.panel[data-flavor="success"] { --panel-accent: #16a34a; --panel-bg: #ecfdf5;}[data-color-scheme="dark"] .panel[data-flavor="tip"] { --panel-bg: #432e0e;}[data-color-scheme="dark"] .panel[data-flavor="info"] { --panel-bg: #1e2a5c;}[data-color-scheme="dark"] .panel[data-flavor="warning"] { --panel-bg: #4a1212;}[data-color-scheme="dark"] .panel[data-flavor="success"] { --panel-bg: #0d3b21;}.panel-icon-button { background: none; border: none; cursor: pointer; padding: 4px; color: var(--panel-accent, #888); display: flex; align-items: center; justify-content: center; margin-top: 2px;}.panel-icon-button:hover { opacity: 0.75;}.panel-body { flex-grow: 1; min-width: 0;}