Card with Ordered Children
In this example, we create a Card container block whose children are an ordered sequence: a cardHeader followed by a cardBody, exactly one of each. The sequence compiles to the ProseMirror content expression cardHeader cardBody, so the order is enforced by the document schema rather than by a repair pass — a card built as [body, header] is rejected before it can reach the document.
The header is a content-bearing container: it has content: "inline" for its rich-text title and children for optional blocks beneath it, both placed with the single contentRef.
Try it out: click the button below the editor to insert a card the wrong way round, and read the error the schema gives back.
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 { useState } from "react";import { RiLayoutRowLine } from "react-icons/ri";import { createCard, createCardBody, createCardHeader } from "./Card";import "./styles.css";// Schema with the default blocks plus the three blocks the card is made of.const schema = BlockNoteSchema.create().extend({ blockSpecs: { ...defaultBlockSpecs, card: createCard(), cardHeader: createCardHeader(), cardBody: createCardBody(), },});// Slash menu item to insert a card. It passes no children: the card's content// expression (`cardHeader cardBody`) tells BlockNote exactly what to fill it// with, so a container can never be created in an invalid state.const insertCard = (editor: typeof schema.BlockNoteEditor) => ({ title: "Card", subtext: "A header followed by a body, in that order", onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: "card", }), aliases: ["card", "panel", "sequence"], group: "Basic blocks", icon: <RiLayoutRowLine />,});export default function App() { // What ProseMirror says when a card is built the wrong way round. const [rejection, setRejection] = useState<string | undefined>(); const editor = useCreateBlockNote({ schema, initialContent: [ { type: "paragraph", content: "A card's children are an ordered sequence: a header, then a body.", }, { type: "card", props: { accent: "violet" }, children: [ { type: "cardHeader", content: "Ordered children", }, { type: "cardBody", children: [ { type: "paragraph", content: "The header holds rich text of its own — try bolding a word in it.", }, { type: "paragraph", content: "The body holds any blocks. Press '/' here to add a heading or a list.", }, ], }, ], }, { type: "paragraph", content: "Press '/' anywhere to insert another card.", }, { type: "paragraph", }, ], }); // Building a card as [body, header] is not something BlockNote has to check // for: the block simply doesn't fit the schema, and inserting it throws. const insertReversedCard = () => { try { editor.insertBlocks( [ { type: "card", children: [ { type: "cardBody", children: [{ type: "paragraph" }] }, { type: "cardHeader" }, ], }, ], editor.document[0], "before", ); setRejection("Unexpectedly accepted!"); } catch (error) { setRejection((error as Error).message); } }; return ( <div className={"wrapper"}> <div className={"item"}> <BlockNoteView editor={editor} slashMenu={false}> <SuggestionMenuController triggerCharacter={"/"} getItems={async (query) => { const defaultItems = getDefaultReactSlashMenuItems(editor); const lastBasicBlockIndex = defaultItems.findLastIndex( (item) => item.group === "Basic blocks", ); defaultItems.splice( lastBasicBlockIndex + 1, 0, insertCard(editor), ); return filterSuggestionItems(defaultItems, query); }} /> </BlockNoteView> </div> <div className={"rejection"}> <button type={"button"} onClick={insertReversedCard}> Try inserting a card as [body, header] </button> {rejection && <code>{rejection}</code>} </div> </div> );}import { createReactBlockSpec } from "@blocknote/react";import { MdPalette } from "react-icons/md";import "./styles.css";// The accents the card can cycle between.export const cardAccents = ["violet", "amber", "teal"] as const;// The card's header: its own rich-text title, plus optional extra blocks// beneath it. `content: "inline"` and `children` together make it a// *content-bearing* container — one editable region holding the title first// and the children after it, both placed with the single `contentRef`.//// `placement: "containerOnly"` keeps it out of the document root: a header// only makes sense inside a card.export const createCardHeader = createReactBlockSpec( { type: "cardHeader", propSchema: {}, content: "inline", children: { min: 0 }, placement: "containerOnly", }, { render: (props) => <div className={"card-header"} ref={props.contentRef} />, },);// The card's body: any blocks, at least one.export const createCardBody = createReactBlockSpec( { type: "cardBody", propSchema: {}, content: "none", children: { min: 1 }, placement: "containerOnly", }, { render: (props) => <div className={"card-body"} ref={props.contentRef} />, },);// The card itself. `sequence` names each child *position* in order, so the// config compiles to the ProseMirror content expression// `cardHeader cardBody` — a header, then a body, exactly one of each.// ProseMirror enforces it: a card built as [body, header] is rejected before// it can reach the document.export const createCard = createReactBlockSpec( { type: "card", propSchema: { accent: { default: "violet", values: ["violet", "amber", "teal"], }, }, content: "none", children: { sequence: [ { allow: { blocks: false, containers: ["cardHeader"] } }, { allow: { blocks: false, containers: ["cardBody"] } }, ], }, }, { render: (props) => { const cycleAccent = () => { const index = cardAccents.findIndex( (accent) => accent === props.block.props.accent, ); props.editor.updateBlock(props.block, { type: "card", props: { accent: cardAccents[(index + 1) % cardAccents.length] }, }); }; return ( <div className={"card"}> <button className={"card-accent-button"} type={"button"} contentEditable={false} onClick={cycleAccent} aria-label={`Cycle card accent (current: ${props.block.props.accent})`} title={`Click to cycle accent (current: ${props.block.props.accent})`} > <MdPalette size={16} /> </button> {/* The card has no content of its own, so its editable region is just its children — the header and the body, in that order. */} <div className={"card-slots"} ref={props.contentRef} /> </div> ); }, },);.wrapper { display: flex; flex-direction: column; gap: 0.5rem; height: 100%;}.item { border-radius: 0.5rem; flex: 1; overflow: auto;}.rejection { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; padding-inline: 54px;}.rejection code { font-size: 0.75rem; opacity: 0.75;}.card { border: 1px solid var(--card-accent, #7c3aed); border-radius: 6px; display: flex; flex-grow: 1; gap: 8px; overflow: hidden; padding: 8px 12px;}.card[data-accent="amber"] { --card-accent: #d97706;}.card[data-accent="teal"] { --card-accent: #0d9488;}.card-accent-button { align-items: center; background: none; border: none; color: var(--card-accent, #7c3aed); cursor: pointer; display: flex; margin-top: 4px; padding: 0;}.card-accent-button:hover { opacity: 0.75;}.card-slots { flex-grow: 1; min-width: 0;}/* The header's two regions. A content-bearing container's editable region holds its own content first and its children after it — both carry stable attributes derived from the block type, so they can be styled separately without any extra config. */.card-header [data-content-type="cardHeader"] { border-bottom: 1px solid var(--card-accent, #7c3aed); font-weight: 600; padding-bottom: 4px;}.card-header [data-children-of="cardHeader"] { font-size: 0.875rem; opacity: 0.8;}.card-body { padding-top: 4px;}