Macro Block
In this example, we create a custom Macro block using the vanilla createBlockSpec API from @blocknote/core. Each macro block stores an id prop, and the id is used to look up "before" and "after" HTML strings in a global map. Those strings are injected as html on either side of the editable inline content.
This pattern is useful when you want a block whose decoration is driven by a runtime registry — for example, server-driven labels, citations, or templated wrappers.
Try it out: Edit the inline text inside each macro block. Notice that the "before" and "after" decorations stay non-editable, and that swapping the id in App.tsx would change the surrounding HTML without touching the block's content.
Relevant Docs:
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";import "@blocknote/core/fonts/inter.css";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { useCreateBlockNote } from "@blocknote/react";import { macroBlock } from "./Macro";import "./styles.css";const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, macro: macroBlock(), },});export default function App() { const editor = useCreateBlockNote({ schema, initialContent: [ { type: "paragraph", content: "Below are two macro blocks. Each looks up its before/after content from a global registry by id — the first uses HTML strings, the second uses a live <input> element:", }, { type: "macro", props: { id: "warning" }, content: "Stay hydrated while editing", }, { type: "macro", props: { id: "note" }, content: "Type a note here, then add a label on the left", }, { type: "paragraph", content: "Edit the inline text inside each macro to see it stay live.", }, ], }); return <BlockNoteView editor={editor} />;}import { createBlockSpec, defaultProps } from "@blocknote/core";import { macroRegistry } from "./macroRegistry";// The Macro block — built with the vanilla `createBlockSpec` API.//// It carries a single `id` prop that is used to look up the HTML that should// be rendered in the "before" and "after" slots around the block's editable// inline content. This can actually be in any structure, it is in your controlexport const macroBlock = createBlockSpec( { type: "macro", propSchema: { textAlignment: defaultProps.textAlignment, textColor: defaultProps.textColor, id: { default: "" as const, }, }, content: "inline", }, { render: (block) => { const wrapper = document.createElement("div"); wrapper.className = "macro-block"; // We pull from the "registry" what this block should insert before and after const definition = macroRegistry[block.props.id]; const before = document.createElement("div"); before.className = "macro-slot macro-slot-before"; before.contentEditable = "false"; const beforeContent = definition?.before ?? ""; if (typeof beforeContent === "string") { before.innerHTML = beforeContent; } else { before.appendChild(beforeContent); } const content = document.createElement("div"); content.className = "macro-content"; const after = document.createElement("div"); after.className = "macro-slot macro-slot-after"; after.contentEditable = "false"; const afterContent = definition?.after ?? ""; if (typeof afterContent === "string") { after.innerHTML = afterContent; } else { after.appendChild(afterContent); } // You have full control over this HTML structure // and can inject the rich-text content wherever you want wrapper.append(before, content, after); return { dom: wrapper, contentDOM: content, }; }, },);// A global, runtime-mutable registry that maps a macro id to the content that// should be injected before and after the block's editable inline content.//// Each slot can be either:// - an HTML string → injected via innerHTML// - an HTMLElement → injected via appendChild (lets you put live, stateful// DOM into the slot — e.g. an <input>)//// In a real application this could be populated from a server response, a// shared module, or any other side channel — the block render function only// reads from it.export type MacroDefinition = { before: string | HTMLElement; after: string | HTMLElement;};// An interactive "before" slot: a real <input> that lives in the registry and// gets appended into whichever macro block uses this id. This mirrors the// pattern from the Alert block's title input — but here it's authored as// vanilla DOM and managed by the registry rather than by React state.const labelInput = document.createElement("input");labelInput.className = "macro-label-input";labelInput.type = "text";labelInput.placeholder = "Label…";labelInput.setAttribute("aria-label", "Macro label");export const macroRegistry: Record<string, MacroDefinition> = { warning: { before: ` <span class="macro-badge macro-badge-warning"> <span class="macro-emoji">⚠️</span> Heads up </span> `, after: ` <a class="macro-link" href="https://www.blocknotejs.org/" target="_blank" rel="noreferrer"> Read the docs → </a> `, }, note: { // An HTMLElement instead of a string — the macro block will appendChild it. before: labelInput, after: ` <span class="macro-meta">— the input on the left is a live DOM node</span> `, },};.macro-block { align-items: stretch; background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 6px; display: flex; flex-direction: column; flex-grow: 1; gap: 6px; padding: 8px 12px;}[data-color-scheme="dark"] .macro-block { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.08);}.macro-content { flex-grow: 1; min-width: 0;}.macro-slot { align-items: center; align-self: flex-start; display: inline-flex; user-select: none;}.macro-badge { align-items: center; border-radius: 999px; display: inline-flex; font-size: 0.75em; font-weight: 600; gap: 4px; padding: 2px 10px;}.macro-badge-greeting { background: #e0e7ff; color: #312e81;}.macro-badge-warning { background: #fef3c7; color: #78350f;}.macro-emoji { font-size: 1em;}.macro-meta { color: rgba(0, 0, 0, 0.45); font-size: 0.8em; font-style: italic;}[data-color-scheme="dark"] .macro-meta { color: rgba(255, 255, 255, 0.5);}.macro-link { color: #2563eb; font-size: 0.8em; font-weight: 500; text-decoration: none;}.macro-link:hover { text-decoration: underline;}.macro-label-input { background: transparent; border: none; border-radius: 3px; color: inherit; cursor: text; font-family: inherit; font-size: 0.8em; font-weight: 600; outline: none; padding: 2px 6px; width: 140px;}.macro-label-input::placeholder { color: rgba(0, 0, 0, 0.35); font-weight: 500;}.macro-label-input:hover:not(:focus) { background-color: rgba(0, 0, 0, 0.04);}[data-color-scheme="dark"] .macro-label-input::placeholder { color: rgba(255, 255, 255, 0.4);}[data-color-scheme="dark"] .macro-label-input:hover:not(:focus) { background-color: rgba(255, 255, 255, 0.06);}