diff --git a/examples/06-custom-schema/14-tabs-block/.bnexample.json b/examples/06-custom-schema/14-tabs-block/.bnexample.json
new file mode 100644
index 0000000000..5d0eae0241
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/.bnexample.json
@@ -0,0 +1,12 @@
+{
+ "playground": true,
+ "docs": false,
+ "author": "claude",
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+ "dependencies": {
+ "react-icons": "^5.5.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2"
+ }
+}
diff --git a/examples/06-custom-schema/14-tabs-block/README.md b/examples/06-custom-schema/14-tabs-block/README.md
new file mode 100644
index 0000000000..0ac97df853
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/README.md
@@ -0,0 +1,46 @@
+# Tabs Block
+
+A tab set built on the container block API. `tabs` is a container whose
+`children` are restricted to `tab` panels, and `tab` is a `placeable:
+"namedOnly"` container, so a panel can only ever exist inside a tab set —
+the schema enforces both.
+
+Each panel holds any block. A panel's label is document content and lives in
+its props. Which panel is open is not: it belongs to each reader, so it is kept
+outside the document, in `localStorage` keyed by the tab set's id. Switching
+tabs is therefore not an undo step and is not sent to collaborators.
+
+**Try it out:** Click a tab to open it, click the open tab for its menu, and
+drag a tab to reorder it.
+
+## What a tab set needs beyond the container API
+
+**Revealing the panel the caret lands in.** A hidden panel is still part of the
+document, so the editor will move content into it: Backspace at the start of
+the block after a tab set pulls that block into the last panel, which may not
+be the open one. `useRevealCaretPanel` opens whichever panel the caret ends up
+in, using `editor.onSelectionChange`, so every route in (Backspace, Delete,
+arrow keys, drag and drop, paste) is covered at once.
+
+**Moving the caret when a tab is clicked.** An explicit switch takes the caret
+with it when the caret was inside the set. Otherwise the reveal above would
+immediately reopen the panel it was left in.
+
+**A menu instead of buttons.** Clicking the open tab opens a menu to rename,
+move or delete it. It is built from `useComponentsContext()`, so it matches
+whichever UI library the editor uses.
+
+**Drag to reorder.** Tabs are sortable with dnd-kit. A tab keeps its block id
+when it moves, so the reader's open tab follows it.
+
+## Known limitation
+
+Emptying a panel removes it, label included, because the container repair
+treats a panel holding only an empty paragraph as empty. Removing the last
+non-empty panel can therefore dissolve the whole tab set. The container API
+has no way for a block to opt out of this yet.
+
+**Relevant Docs:**
+
+- [Container Blocks](/docs/features/custom-schemas/container-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
diff --git a/examples/06-custom-schema/14-tabs-block/index.html b/examples/06-custom-schema/14-tabs-block/index.html
new file mode 100644
index 0000000000..bfda1619c2
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Tabs Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/14-tabs-block/main.tsx b/examples/06-custom-schema/14-tabs-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/14-tabs-block/package.json b/examples/06-custom-schema/14-tabs-block/package.json
new file mode 100644
index 0000000000..de4c50d3c5
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@blocknote/example-custom-schema-tabs-block",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/06-custom-schema/14-tabs-block/src/App.tsx b/examples/06-custom-schema/14-tabs-block/src/App.tsx
new file mode 100644
index 0000000000..d9add01a90
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/App.tsx
@@ -0,0 +1,109 @@
+import { BlockNoteSchema } 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 { RiLayoutTopLine } from "react-icons/ri";
+
+import { createTab, createTabs } from "./Tabs";
+import "./styles.css";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ tabs: createTabs(),
+ tab: createTab(),
+ },
+});
+
+// A tab set is only useful with its panels, so the slash menu inserts both.
+function insertTabs(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Tabs",
+ subtext: "A tab strip with one panel per tab",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "tabs",
+ children: [
+ {
+ type: "tab",
+ props: { label: "First" },
+ children: [{ type: "paragraph", content: "First panel" }],
+ },
+ {
+ type: "tab",
+ props: { label: "Second" },
+ children: [{ type: "paragraph", content: "Second panel" }],
+ },
+ ],
+ } as any),
+ aliases: ["tabs", "tab"],
+ group: "Basic blocks",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Tabs built on the container API. Each panel holds any block.",
+ },
+ {
+ // A persistent ID so the open tab is remembered across reloads: the
+ // choice is stored per tab-set id, and a fresh document would
+ // otherwise get a fresh one.
+ id: "tabs-demo",
+ type: "tabs",
+ children: [
+ {
+ // Persistent IDs alongside the set's, so the remembered choice
+ // still names a panel after a reload.
+ id: "tab-install",
+ type: "tab",
+ props: { label: "Install" },
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Install" },
+ { type: "paragraph", content: "Run the installer, then reboot." },
+ { type: "bulletListItem", content: "Any block works in here" },
+ ],
+ },
+ {
+ id: "tab-configure",
+ type: "tab",
+ props: { label: "Configure" },
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Configure" },
+ { type: "paragraph", content: "Edit the config file." },
+ ],
+ },
+ ],
+ } as any,
+ { type: "paragraph", content: "Press '/' to insert another tab set." },
+ { type: "paragraph" },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertTabs(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx
new file mode 100644
index 0000000000..68bf93648e
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx
@@ -0,0 +1,503 @@
+import {
+ DndContext,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ horizontalListSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { Block, BlockNoteEditor } from "@blocknote/core";
+import { createReactBlockSpec, useComponentsContext } from "@blocknote/react";
+import type React from "react";
+import {
+ RiArrowLeftLine,
+ RiArrowRightLine,
+ RiDeleteBinLine,
+ RiPencilLine,
+} from "react-icons/ri";
+import { useEffect, useState, useSyncExternalStore } from "react";
+
+import "./styles.css";
+
+// Which tab is open is *view state*, not content: it belongs to the reader,
+// not to the document. Block props are part of the document, so putting it
+// there would sync it to every collaborator and add an undo step for a
+// glance. BlockNote's own toggle blocks keep their state out of the document
+// the same way, in `localStorage` keyed by block id.
+//
+// Nothing is stored until the reader picks a tab: with no entry, the first
+// panel is the open one. The strip and the panels render in separate React
+// roots, which is why this is a store rather than a context.
+const openTabs = new Map();
+const listeners = new Set<() => void>();
+
+function storageKey(tabsId: string) {
+ return `tabs-open-${tabsId}`;
+}
+
+/** The panel the reader opened in this set, if they have opened one. */
+function openTabId(tabsId: string): string | undefined {
+ const known = openTabs.get(tabsId);
+ if (known !== undefined) {
+ return known;
+ }
+ try {
+ return window.localStorage.getItem(storageKey(tabsId)) ?? undefined;
+ } catch {
+ // Private windows and blocked site data: fall back to the first panel.
+ return undefined;
+ }
+}
+
+function openTab(tabsId: string, tabId: string) {
+ openTabs.set(tabsId, tabId);
+ try {
+ window.localStorage.setItem(storageKey(tabsId), tabId);
+ } catch {
+ // Not being able to remember the choice is not worth failing over.
+ }
+ listeners.forEach((listener) => listener());
+}
+
+/**
+ * The panel that is open in this set: the reader's choice when they have made
+ * one that still names a panel, and the first panel otherwise.
+ */
+function useActiveTabId(tabsId: string, tabs: Block[]): string {
+ const chosen = useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ () => openTabId(tabsId),
+ () => undefined,
+ );
+
+ // `tabs` is never empty: the schema gives the set `min: 1`.
+ return tabs.find((tab) => tab.id === chosen)?.id ?? tabs[0].id;
+}
+
+/**
+ * The panels of the tab set being rendered, read back from the editor.
+ *
+ * `props.block` is the snapshot the render closed over, so a handler that has
+ * just inserted or removed a panel cannot see its own change there. The set
+ * is on screen, so it not being in the document means this component outlived
+ * its block, which is a bug rather than a state to render around.
+ */
+function panelsOf(editor: BlockNoteEditor, tabsId: string) {
+ const tabs = editor.getBlock(tabsId);
+ if (!tabs) {
+ throw new Error(`Tab set "${tabsId}" is not in the document`);
+ }
+ return tabs.children;
+}
+
+/** Whether `block` is, or contains, the block with `id`. */
+function contains(block: Block, id: string): boolean {
+ return block.id === id || block.children.some((child) => contains(child, id));
+}
+
+/**
+ * Opens whichever panel the caret ends up in.
+ *
+ * A hidden panel is still part of the document, so the editor will happily
+ * move content into it - Backspace at the start of the block *after* a tab
+ * set pulls that block into the last panel, which may not be the open one.
+ * Without this the block simply disappears from view. Reacting to the
+ * selection covers every route in (Backspace, Delete, arrow keys, drag and
+ * drop, paste) instead of patching one key at a time.
+ */
+function useRevealCaretPanel(
+ editor: BlockNoteEditor,
+ tabsId: string,
+) {
+ useEffect(
+ () =>
+ editor.onSelectionChange(() => {
+ // Read the panels back from the editor rather than closing over
+ // them: the block that just moved into one is not in the list this
+ // effect was created with. A collaborator can dissolve the set
+ // between the selection changing and this running, so a missing one
+ // is expected here, unlike in the handlers below.
+ const tabs = editor.getBlock(tabsId)?.children;
+ if (!tabs) {
+ return;
+ }
+ const here = editor.getTextCursorPosition().block;
+ const panel = tabs.find((tab) => contains(tab, here.id));
+ if (panel && openTabId(tabsId) !== panel.id) {
+ openTab(tabsId, panel.id);
+ }
+ }),
+ [editor, tabsId],
+ );
+}
+
+/**
+ * A tab panel. It only ever exists inside a `tabs` block, so it declares
+ * `placeable: "namedOnly"` — the schema then keeps it out of every other
+ * position, and BlockNote dissolves it into its blocks if it is moved out.
+ *
+ * Its props carry the label only. Which panel is open is the reader's own
+ * state, so it is held outside the document (see the store above) - a block
+ * prop would travel to every collaborator and cost an undo step.
+ */
+export const createTab = createReactBlockSpec(
+ {
+ type: "tab",
+ propSchema: {
+ label: { default: "Tab" },
+ },
+ content: "none",
+ children: { allow: "blocks" },
+ placeable: "namedOnly",
+ },
+ {
+ // The panel reads its own open state, so it re-renders when the reader
+ // switches tabs without the document changing at all.
+ render: function TabPanel(props) {
+ // `placeable: "namedOnly"` means a panel only ever exists inside a tab
+ // set, so not finding one is a broken document rather than a state to
+ // render around.
+ const set = props.editor.getParentBlock(props.block.id);
+ if (!set) {
+ throw new Error(`Tab panel "${props.block.id}" has no tab set`);
+ }
+ const open = useActiveTabId(set.id, set.children) === props.block.id;
+
+ return (
+
+ );
+ },
+ },
+);
+
+/**
+ * One tab in the strip, draggable to reorder. The whole tab is the handle, and
+ * the sensor below only starts a drag after a few pixels of movement, so a
+ * plain click still opens the tab or its menu.
+ */
+function SortableTab(props: {
+ id: string;
+ active: boolean;
+ editing: boolean;
+ children: React.ReactNode;
+}) {
+ // Not draggable while its title is being edited, so selecting text in the
+ // field does not pick the tab up.
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: props.id, disabled: props.editing });
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+/** The tab strip plus the panels. */
+export const createTabs = createReactBlockSpec(
+ {
+ type: "tabs",
+ propSchema: {},
+ content: "none",
+ children: { allow: ["tab"], min: 1 },
+ },
+ {
+ render: function TabStrip(props) {
+ // The editor's own menu primitives, so the tab menu matches whichever
+ // UI library `BlockNoteView` is using (Mantine, Ariakit or ShadCN)
+ // instead of being styled by hand.
+ const Components = useComponentsContext()!;
+ // A few pixels of movement before a drag starts, so clicking a tab
+ // still opens it rather than picking it up.
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+ );
+ // The tab whose title is being edited in place, if any.
+ const [editingId, setEditingId] = useState(undefined);
+ const tabs = props.block.children;
+ useRevealCaretPanel(props.editor, props.block.id);
+ const active = useActiveTabId(props.block.id, tabs);
+ const activeIndex = tabs.findIndex((tab: any) => tab.id === active);
+
+ // Removing a panel does not re-render its siblings: a panel re-renders
+ // when its own block changes or when this store notifies, and a
+ // sibling disappearing is neither. Each survivor therefore keeps the
+ // `open` it last computed, which was `false` for all of them while the
+ // removed panel was the chosen one - so the set would show nothing at
+ // all. Storing the panel actually shown is what tells them.
+ //
+ // Nothing is stored until the reader has chosen, so this only ever
+ // repairs a choice, never makes one.
+ const ids = tabs.map((tab: any) => tab.id).join();
+ useEffect(() => {
+ const stored = openTabId(props.block.id);
+ if (stored !== undefined && stored !== active) {
+ openTab(props.block.id, active);
+ }
+ // `tabs` is a fresh array on every render; the ids are what matter.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ids, active, props.block.id]);
+
+ const onDragEnd = (event: DragEndEvent) => {
+ const from = tabs.findIndex((tab: any) => tab.id === event.active.id);
+ const to = tabs.findIndex((tab: any) => tab.id === event.over?.id);
+ if (from !== -1 && to !== -1 && from !== to) {
+ move(from, to - from);
+ }
+ };
+
+ // The caret follows the switch when it was inside the tab set: leaving
+ // it in the panel that just closed would put the cursor somewhere the
+ // reader cannot see, and `useRevealCaretPanel` would immediately reopen
+ // that panel and undo the switch.
+ const open = (index: number) => {
+ const here = props.editor.getTextCursorPosition().block;
+ const caretInside =
+ props.editor.isFocused() &&
+ tabs.some((tab: any) => contains(tab, here.id));
+
+ // Opening a tab touches no document state, so it creates no undo
+ // step and no change for collaborators.
+ openTab(props.block.id, tabs[index].id);
+
+ if (caretInside) {
+ const landing = tabs[index].children[0];
+ if (!landing) {
+ throw new Error("A tab panel should hold a block for the caret");
+ }
+ props.editor.setTextCursorPosition(landing, "start");
+ }
+ };
+
+ const addTab = () => {
+ props.editor.insertBlocks(
+ [{ type: "tab", props: { label: `Tab ${tabs.length + 1}` } } as any],
+ tabs[tabs.length - 1],
+ "after",
+ );
+
+ // Land in the new panel so it can be typed into straight away.
+ const next = panelsOf(props.editor, props.block.id);
+ const added = next[next.length - 1];
+ const landing = added.children[0];
+ if (!landing) {
+ throw new Error("A new tab panel should hold a block to type in");
+ }
+ openTab(props.block.id, added.id);
+ props.editor.setTextCursorPosition(landing, "start");
+ props.editor.focus();
+ };
+
+ const removeTab = (index: number) => {
+ if (tabs.length === 1) {
+ return;
+ }
+ props.editor.removeBlocks([tabs[index]]);
+ // One tab always survives, since the guard above refuses to remove
+ // the last one.
+ const next = panelsOf(props.editor, props.block.id);
+ openTab(props.block.id, next[Math.min(index, next.length - 1)].id);
+ };
+
+ const move = (index: number, by: number) => {
+ const to = index + by;
+ if (to < 0 || to >= tabs.length) {
+ return;
+ }
+ // One transaction, so a move is a single undo step - and so the id
+ // is free again by the time it is re-inserted. Keeping it matters:
+ // the reader's open panel is remembered by id, and a new one would
+ // silently move their choice to another tab.
+ props.editor.transact(() => {
+ const moving = tabs[index];
+ props.editor.removeBlocks([moving]);
+ props.editor.insertBlocks(
+ [moving as any],
+ tabs[to],
+ by < 0 ? "before" : "after",
+ );
+ });
+ };
+
+ // Arrow keys move between tabs, as a tablist is expected to.
+ const onStripKeyDown = (event: React.KeyboardEvent) => {
+ const deltas: Record = { ArrowLeft: -1, ArrowRight: 1 };
+ const next =
+ event.key === "Home"
+ ? 0
+ : event.key === "End"
+ ? tabs.length - 1
+ : event.key in deltas
+ ? (activeIndex + deltas[event.key] + tabs.length) % tabs.length
+ : undefined;
+ if (next === undefined) {
+ return;
+ }
+ event.preventDefault();
+ open(next);
+ // Keep the strip focused so the next arrow keeps stepping.
+ (event.currentTarget as HTMLElement)
+ .querySelectorAll("[role='tab']")
+ [next]?.focus();
+ };
+
+ return (
+
+ {/* Chrome, not document content: it lives outside `contentRef`. */}
+
+ );
+ },
+ },
+);
diff --git a/examples/06-custom-schema/14-tabs-block/src/styles.css b/examples/06-custom-schema/14-tabs-block/src/styles.css
new file mode 100644
index 0000000000..a0fbd7d563
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/styles.css
@@ -0,0 +1,81 @@
+.tabs {
+ border: 1px solid #e4e4e7;
+ border-radius: 12px;
+ margin: 8px 0;
+ padding: 10px 14px 14px;
+}
+
+.tabs-strip {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ user-select: none;
+}
+
+.tabs-tab {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+}
+
+.tabs-tab[data-active="true"] {
+ background: #f1f2f4;
+}
+
+/* Scoped to the tab's own label: the tab menu's dropdown renders inside the
+ tab, and a plain `button` selector would restyle its items too. */
+.tabs-label,
+.tabs-add {
+ border: none;
+ background: none;
+ cursor: pointer;
+ font: inherit;
+ color: #8a8f98;
+ padding: 5px 14px;
+ border-radius: 999px;
+}
+
+.tabs-tab[data-active="true"] .tabs-label {
+ color: #1f2024;
+ font-weight: 600;
+}
+
+.tabs-label:focus-visible,
+.tabs-add:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -2px;
+}
+
+/* Editing chrome stays out of the way until the reader means to use it. The
+ space is reserved either way, so revealing it does not shift the strip. */
+.tabs-add {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s ease;
+}
+
+.tabs-strip:hover .tabs-add,
+.tabs-strip:focus-within .tabs-add {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.tabs-body {
+ /* An empty panel still needs to be clickable. */
+ min-height: 1.5rem;
+}
+
+/* Only the open panel is shown; the others stay in the document. The panel
+ sets this attribute itself from per-reader state, so it is always present
+ and `[data-active="false"]` would match too - `:not(...)` keeps it working
+ either way. */
+.tab-panel:not([data-active="true"]) {
+ display: none;
+}
+
+/* The title while it is being renamed: the same pill, now a text field. */
+input.tabs-label {
+ cursor: text;
+ outline: none;
+ width: 10ch;
+}
diff --git a/examples/06-custom-schema/14-tabs-block/tsconfig.json b/examples/06-custom-schema/14-tabs-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/06-custom-schema/14-tabs-block/vite-env.d.ts b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/14-tabs-block/vite.config.ts b/examples/06-custom-schema/14-tabs-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/examples/06-custom-schema/15-stepper-block/.bnexample.json b/examples/06-custom-schema/15-stepper-block/.bnexample.json
new file mode 100644
index 0000000000..c39c16984d
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/.bnexample.json
@@ -0,0 +1,9 @@
+{
+ "playground": true,
+ "docs": false,
+ "author": "claude",
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+ "dependencies": {
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/15-stepper-block/README.md b/examples/06-custom-schema/15-stepper-block/README.md
new file mode 100644
index 0000000000..476e5cb817
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/README.md
@@ -0,0 +1,57 @@
+# Stepper Block
+
+Numbered steps built on the container block API. `stepper` is a container
+restricted to `step` children, and `step` is a `placeable: "namedOnly"`
+container, so a step can only exist inside a stepper.
+
+Each step holds any blocks: headings, lists, code. The numbers are a CSS
+counter, so they stay correct as steps are added, removed, reordered or
+undone. Nothing in the document has to be kept in sync.
+
+**Add step** below the last step adds an untitled one.
+
+## Things worth knowing
+
+**Enter starts the next step.** By default, Enter on an empty block at the end
+of a step moves that block out below the whole stepper, so the keyboard alone
+cannot start a new step. `enterStartsNextStep` makes Enter work the way it does
+in a list:
+
+1. Enter at the end of a step adds a block to that step.
+2. Enter again, on the empty block, starts the next step and puts the caret
+ in its title.
+3. Enter again, on the empty title, leaves the stepper. It leaves a paragraph
+ behind, not the empty heading the step was created with.
+
+Keyboard shortcuts are the one thing the editor API cannot provide, so this
+is written as an extension, passed as the third argument of
+`createReactBlockSpec`.
+
+## Known limitations
+
+**The title is not enforced.** A step has no text of its own: its title is
+simply its first child, a heading. The user can turn that heading into any
+other block, delete it, or put blocks above it. A real title needs a block
+with its own text that may only appear inside a stepper, and the container API
+cannot express that yet: `placeable: "namedOnly"` requires a container (a
+block without content), and `children.allow` can only name containers.
+
+**A step with an empty paragraph as title is removed.** BlockNote treats a
+container child that holds only an empty paragraph as emptied out. It deletes
+that child the next time it repairs the stepper, for example when any other
+step is removed. New steps start with an empty heading, because a heading is
+the right block for a title, and an empty heading does not count as empty. But
+a step whose title the user turns into an empty paragraph is removed.
+
+## What the container API gives you for free
+
+- Backspace at the start of a step's title first turns it into a paragraph.
+ A second Backspace moves it to the end of the previous step, or above the
+ stepper if it is in the first step.
+- Emptying a step drops it. Deleting the last step removes the stepper.
+- Shift-Tab unnests inside a step and stops at the step's edge.
+
+**Relevant Docs:**
+
+- [Container Blocks](/docs/features/custom-schemas/container-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
diff --git a/examples/06-custom-schema/15-stepper-block/index.html b/examples/06-custom-schema/15-stepper-block/index.html
new file mode 100644
index 0000000000..29f8a789c7
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Stepper Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/15-stepper-block/main.tsx b/examples/06-custom-schema/15-stepper-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/15-stepper-block/package.json b/examples/06-custom-schema/15-stepper-block/package.json
new file mode 100644
index 0000000000..5e67e12486
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-stepper-block",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/06-custom-schema/15-stepper-block/src/App.tsx b/examples/06-custom-schema/15-stepper-block/src/App.tsx
new file mode 100644
index 0000000000..e9bee1fb9f
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/App.tsx
@@ -0,0 +1,108 @@
+import { BlockNoteSchema } 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 { RiListOrdered2 } from "react-icons/ri";
+
+import { createStep, createStepper } from "./Stepper";
+import "./styles.css";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ stepper: createStepper(),
+ step: createStep(),
+ },
+});
+
+function insertStepper(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Stepper",
+ subtext: "Numbered steps, each holding any blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "stepper",
+ children: [
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "First step" },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Second step" },
+ ],
+ },
+ ],
+ } as any),
+ aliases: ["stepper", "steps", "guide", "walkthrough"],
+ group: "Basic blocks",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "A stepper built on the container API. Numbering is CSS.",
+ },
+ {
+ type: "stepper",
+ children: [
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Install" },
+ { type: "paragraph", content: "Download and run the installer." },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Configure" },
+ { type: "paragraph", content: "Any block works inside a step:" },
+ { type: "bulletListItem", content: "lists" },
+ { type: "codeBlock", content: "code()" },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Ship" },
+ { type: "paragraph", content: "Deploy it." },
+ ],
+ },
+ ],
+ } as any,
+ { type: "paragraph", content: "Press '/' to insert another stepper." },
+ { type: "paragraph" },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertStepper(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx
new file mode 100644
index 0000000000..ae3846defb
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx
@@ -0,0 +1,164 @@
+import type { BlockNoteEditor } from "@blocknote/core";
+import { createExtension } from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+/**
+ * A new, untitled step.
+ *
+ * Its title is an empty heading, the right block for a title. This also keeps
+ * the step alive: BlockNote treats a container child holding only an empty
+ * paragraph as emptied out and deletes it the next time it repairs the
+ * stepper, but an empty heading does not count as empty.
+ */
+function newStep() {
+ return {
+ type: "step",
+ children: [{ type: "heading", props: { level: 3 } }],
+ };
+}
+
+/**
+ * Enter on an empty block at the end of a step starts the next step, the way
+ * Enter on an empty list item starts the next item. BlockNote's own Enter
+ * handling would move that block out below the whole stepper, which leaves no
+ * keyboard route to a new step.
+ *
+ * Keyboard shortcuts have no editor-API equivalent, so this is the one piece
+ * that needs an extension, attached through `createReactBlockSpec`'s third
+ * argument.
+ */
+const enterStartsNextStep = createExtension({
+ key: "stepper-enter-starts-next-step",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ const { block } = editor.getTextCursorPosition();
+ const isEmpty =
+ Array.isArray(block.content) &&
+ block.content.length === 0 &&
+ block.children.length === 0;
+ if (!isEmpty) {
+ return false;
+ }
+ const step = editor.getParentBlock(block);
+ if (step?.type !== "step") {
+ return false;
+ }
+
+ // The step holds nothing but this empty block - typically the title of
+ // a step the previous Enter just started - so this Enter leaves the
+ // stepper. The built-in handling would carry the empty heading out;
+ // replacing the step with a paragraph below the stepper leaves the
+ // caret in plain text instead.
+ if (step.children.length === 1) {
+ const stepper = editor.getParentBlock(step);
+ if (!stepper) {
+ throw new Error(`Step "${step.id}" is not inside a stepper`);
+ }
+ const [landing] = editor.transact(() => {
+ const inserted = editor.insertBlocks(
+ [{ type: "paragraph" }],
+ stepper,
+ "after",
+ );
+ // If this was the stepper's only step, the stepper dissolves too.
+ editor.removeBlocks([step]);
+ return inserted;
+ });
+ editor.setTextCursorPosition(landing, "start");
+ return true;
+ }
+
+ // Otherwise only the *last* block of a step opens the next one.
+ if (step.children[step.children.length - 1].id !== block.id) {
+ return false;
+ }
+ const [next] = editor.transact(() => {
+ editor.removeBlocks([block]);
+ return editor.insertBlocks([newStep()], step, "after");
+ });
+ editor.setTextCursorPosition(next.children[0], "start");
+ return true;
+ },
+ },
+});
+
+/** Appends a step and puts the caret in its title. */
+function addStep(editor: BlockNoteEditor, stepperId: string) {
+ // Read back rather than using the render's snapshot of the stepper.
+ const stepper = editor.getBlock(stepperId);
+ if (!stepper) {
+ throw new Error(`Stepper "${stepperId}" is not in the document`);
+ }
+ const [step] = editor.insertBlocks(
+ [newStep()],
+ stepper.children[stepper.children.length - 1],
+ "after",
+ );
+ // Land in the new step's title so it can be named straight away.
+ editor.setTextCursorPosition(step.children[0], "start");
+ editor.focus();
+}
+
+/**
+ * One step. Like a column, a step is defined only in terms of the thing that
+ * holds it, so it declares `placeable: "namedOnly"`: the schema keeps it out
+ * of every other position, and moving one out dissolves it into its blocks.
+ *
+ * A step has no text of its own - its title is simply its first child. The
+ * container API cannot express "a block with its own rich text that may only
+ * appear inside a stepper": `children.allow` names container types only, and
+ * a block with content is not one.
+ */
+export const createStep = createReactBlockSpec(
+ {
+ type: "step",
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ placeable: "namedOnly",
+ },
+ {
+ render: (props) => (
+
+ {/* Chrome, not content: it sits outside `contentRef`. The number
+ comes from a CSS counter. */}
+
+ );
+ },
+ },
+ [enterStartsNextStep],
+);
diff --git a/examples/06-custom-schema/15-stepper-block/src/styles.css b/examples/06-custom-schema/15-stepper-block/src/styles.css
new file mode 100644
index 0000000000..5f1f65ac68
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/styles.css
@@ -0,0 +1,97 @@
+.stepper {
+ /* The rail runs behind the step numbers, from the first one down to the
+ "+" of "Add step". It is drawn once here rather than per step, so it
+ needs no "is this the last step" logic. */
+ position: relative;
+ margin: 8px 0;
+}
+
+.stepper::before {
+ content: "";
+ position: absolute;
+ left: 12px;
+ top: 18px;
+ bottom: 12px;
+ width: 1px;
+ background: var(--bn-colors-border);
+}
+
+/* Numbering is a CSS counter, so it stays correct as steps are added,
+ removed, dragged or undone - nothing to keep in sync in the document. */
+.stepper-steps {
+ counter-reset: step;
+}
+
+.step {
+ display: flex;
+ gap: 12px;
+ padding-bottom: 8px;
+}
+
+.step-rail {
+ flex: none;
+ width: 25px;
+ /* Centres the number on the first line of a step's title. */
+ padding-top: 6px;
+}
+
+/* Every circle gets the editor's background so the rail passes behind it. */
+.step-number,
+.stepper-add-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 25px;
+ height: 25px;
+ box-sizing: border-box;
+ border: 1px solid var(--bn-colors-border);
+ border-radius: 999px;
+ background: var(--bn-colors-editor-background);
+ color: var(--bn-colors-side-menu);
+ font: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ user-select: none;
+}
+
+.step-number {
+ counter-increment: step;
+ color: var(--bn-colors-editor-text);
+}
+
+.step-number::before {
+ content: counter(step);
+}
+
+.stepper-add:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 1px;
+}
+
+.step-body {
+ flex: 1;
+ min-width: 0;
+}
+
+/* A heading's top spacing separates it from the block above; the step's
+ title has none above it, and would sit below its number. */
+.step-body > div > .bn-block-outer:first-child > .bn-block > .bn-block-content {
+ padding-top: 3px;
+}
+
+.stepper-add {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ font: inherit;
+ color: var(--bn-colors-side-menu);
+}
+
+.stepper-add:hover {
+ color: var(--bn-colors-hovered-text);
+}
diff --git a/examples/06-custom-schema/15-stepper-block/tsconfig.json b/examples/06-custom-schema/15-stepper-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/06-custom-schema/15-stepper-block/vite-env.d.ts b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/15-stepper-block/vite.config.ts b/examples/06-custom-schema/15-stepper-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/packages/core/src/extensions/DropCursor/utils.ts b/packages/core/src/extensions/DropCursor/utils.ts
index 25e3985238..5d2e5f58d4 100644
--- a/packages/core/src/extensions/DropCursor/utils.ts
+++ b/packages/core/src/extensions/DropCursor/utils.ts
@@ -40,6 +40,19 @@ export function hasExclusionClassname(
return !!element.closest(`.${exclude}`);
}
+// A node view whose outer element is `display: contents` (e.g. a React
+// container block) has no box of its own, so measure its first rendered child.
+function getNodeRect(node: HTMLElement): DOMRect {
+ let measured: Element = node;
+ while (
+ getComputedStyle(measured).display === "contents" &&
+ measured.firstElementChild
+ ) {
+ measured = measured.firstElementChild;
+ }
+ return measured.getBoundingClientRect();
+}
+
/**
* Computes the viewport rect for a block-level drop cursor (horizontal line between blocks
* or vertical line on left/right edge). Returns null for inline positions or when no DOM node exists.
@@ -79,7 +92,7 @@ export function getBlockDropRect(
return null;
}
- const nodeRect = node.getBoundingClientRect();
+ const nodeRect = getNodeRect(node);
if (isVertical) {
const halfWidth = (width / 2) * scaleX;
@@ -99,10 +112,7 @@ export function getBlockDropRect(
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after) {
top =
- (top +
- (view.nodeDOM(cursorPos.pos) as HTMLElement).getBoundingClientRect()
- .top) /
- 2;
+ (top + getNodeRect(view.nodeDOM(cursorPos.pos) as HTMLElement).top) / 2;
}
const halfHeight = (width / 2) * scaleY;
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index f8ba326538..85210d30ff 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -72,21 +72,33 @@ function setDragImage(view: EditorView, from: number, to = from) {
}
// Parent element is cloned to remove all unselected children without affecting the editor content.
- const parentClone = view.domAtPos(from).node.cloneNode(true) as Element;
+ const parentClone = view.domAtPos(from).node.cloneNode(true) as HTMLElement;
+ // A container block's children sit in a `display: contents` wrapper, which
+ // has no box for the browser to take an image of.
+ if (parentClone.style.display === "contents") {
+ parentClone.style.display = "";
+ }
const parent = view.domAtPos(from).node as Element;
- const getElementIndex = (parentElement: Element, targetElement: Element) =>
- Array.prototype.indexOf.call(parentElement.children, targetElement);
+ // The index of the child of `parent` that holds `node`. A node view (e.g. a
+ // React container block) can wrap a block in more than one element.
+ const getElementIndex = (parentElement: Element, node: globalThis.Node) => {
+ let element = node instanceof Element ? node : node.parentElement;
+ while (element && element.parentElement !== parentElement) {
+ element = element.parentElement;
+ }
+ return Array.prototype.indexOf.call(parentElement.children, element);
+ };
const firstSelectedBlockIndex = getElementIndex(
parent,
// Expects from position to be just before the first selected block.
- view.domAtPos(from + 1).node.parentElement!,
+ view.domAtPos(from + 1).node,
);
const lastSelectedBlockIndex = getElementIndex(
parent,
// Expects to position to be just after the last selected block.
- view.domAtPos(to - 1).node.parentElement!,
+ view.domAtPos(to - 1).node,
);
for (let i = parent.childElementCount - 1; i >= 0; i--) {
@@ -193,6 +205,12 @@ export function dragStart<
const selectedSlice = view.state.selection.content();
const schema = editor.pmSchema;
+ // Hand ProseMirror the dragged nodes as they are. Otherwise the side menu's
+ // `dragstart` handler re-parses them from `blocknote/html` into a
+ // `blockGroup`, which wraps a block that can't stand alone (e.g. a column
+ // or a tab) in a new parent, so the drop inserts a copy of that parent.
+ view.dragging = { slice: selectedSlice, move: true };
+
const clipboardHTML =
view.serializeForClipboard(selectedSlice).dom.innerHTML;
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 2e793bfe93..c80d615aa1 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1512,7 +1512,7 @@ export const examples = {
slug: "custom-schema",
},
readme:
- '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.\n\nThe 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.\n\nWe 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.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Click the panel\'s icon to cycle its flavor. The box re-renders in place; the children are untouched.\n- Watch the JSON panel on the right update as you edit; the panel\'s children appear in `block.children`.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ 'In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs.\n\nThe block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change.\n\nWe also wire up a Slash Menu item to insert the panel.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
},
{
projectSlug: "math-block",
@@ -1618,7 +1618,52 @@ export const examples = {
slug: "custom-schema",
},
readme:
- 'In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout.\n\nThe block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.\n\nWe 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 titled block and its nested children.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to jump into its body.\n- Press Backspace at the start of the first body block to merge it back into the title.\n- Press "/" inside the body and add a code block, heading, or list.\n- Watch the JSON panel on the right update as you edit; the title is `content` and the body is `block.children`.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ 'In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout.\n\nThe block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.\n\nWe also wire up a Slash Menu item to insert the callout.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to jump into its body.\n- Press Backspace at the start of the first body block to merge it back into the title.\n- Press "/" inside the body and add a code block, heading, or list.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
+ {
+ projectSlug: "tabs-block",
+ fullSlug: "custom-schema/tabs-block",
+ pathFromRoot: "examples/06-custom-schema/14-tabs-block",
+ config: {
+ playground: true,
+ docs: false,
+ author: "claude",
+ tags: ["Intermediate", "Blocks", "Custom Schemas"],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ } as any,
+ },
+ title: "Tabs Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ "A tab set built on the container block API. `tabs` is a container whose\n`children` are restricted to `tab` panels, and `tab` is a `placeable:\n\"namedOnly\"` container, so a panel can only ever exist inside a tab set —\nthe schema enforces both.\n\nEach panel holds any block. A panel's label is document content and lives in\nits props. Which panel is open is not: it belongs to each reader, so it is kept\noutside the document, in `localStorage` keyed by the tab set's id. Switching\ntabs is therefore not an undo step and is not sent to collaborators.\n\n**Try it out:** Click a tab to open it, click the open tab for its menu, and\ndrag a tab to reorder it.\n\n## What a tab set needs beyond the container API\n\n**Revealing the panel the caret lands in.** A hidden panel is still part of the\ndocument, so the editor will move content into it: Backspace at the start of\nthe block after a tab set pulls that block into the last panel, which may not\nbe the open one. `useRevealCaretPanel` opens whichever panel the caret ends up\nin, using `editor.onSelectionChange`, so every route in (Backspace, Delete,\narrow keys, drag and drop, paste) is covered at once.\n\n**Moving the caret when a tab is clicked.** An explicit switch takes the caret\nwith it when the caret was inside the set. Otherwise the reveal above would\nimmediately reopen the panel it was left in.\n\n**A menu instead of buttons.** Clicking the open tab opens a menu to rename,\nmove or delete it. It is built from `useComponentsContext()`, so it matches\nwhichever UI library the editor uses.\n\n**Drag to reorder.** Tabs are sortable with dnd-kit. A tab keeps its block id\nwhen it moves, so the reader's open tab follows it.\n\n## Known limitation\n\nEmptying a panel removes it, label included, because the container repair\ntreats a panel holding only an empty paragraph as empty. Removing the last\nnon-empty panel can therefore dissolve the whole tab set. The container API\nhas no way for a block to opt out of this yet.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)",
+ },
+ {
+ projectSlug: "stepper-block",
+ fullSlug: "custom-schema/stepper-block",
+ pathFromRoot: "examples/06-custom-schema/15-stepper-block",
+ config: {
+ playground: true,
+ docs: false,
+ author: "claude",
+ tags: ["Intermediate", "Blocks", "Custom Schemas"],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Stepper Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'Numbered steps built on the container block API. `stepper` is a container\nrestricted to `step` children, and `step` is a `placeable: "namedOnly"`\ncontainer, so a step can only exist inside a stepper.\n\nEach step holds any blocks: headings, lists, code. The numbers are a CSS\ncounter, so they stay correct as steps are added, removed, reordered or\nundone. Nothing in the document has to be kept in sync.\n\n**Add step** below the last step adds an untitled one.\n\n## Things worth knowing\n\n**Enter starts the next step.** By default, Enter on an empty block at the end\nof a step moves that block out below the whole stepper, so the keyboard alone\ncannot start a new step. `enterStartsNextStep` makes Enter work the way it does\nin a list:\n\n1. Enter at the end of a step adds a block to that step.\n2. Enter again, on the empty block, starts the next step and puts the caret\n in its title.\n3. Enter again, on the empty title, leaves the stepper. It leaves a paragraph\n behind, not the empty heading the step was created with.\n\nKeyboard shortcuts are the one thing the editor API cannot provide, so this\nis written as an extension, passed as the third argument of\n`createReactBlockSpec`.\n\n## Known limitations\n\n**The title is not enforced.** A step has no text of its own: its title is\nsimply its first child, a heading. The user can turn that heading into any\nother block, delete it, or put blocks above it. A real title needs a block\nwith its own text that may only appear inside a stepper, and the container API\ncannot express that yet: `placeable: "namedOnly"` requires a container (a\nblock without content), and `children.allow` can only name containers.\n\n**A step with an empty paragraph as title is removed.** BlockNote treats a\ncontainer child that holds only an empty paragraph as emptied out. It deletes\nthat child the next time it repairs the stepper, for example when any other\nstep is removed. New steps start with an empty heading, because a heading is\nthe right block for a title, and an empty heading does not count as empty. But\na step whose title the user turns into an empty paragraph is removed.\n\n## What the container API gives you for free\n\n- Backspace at the start of a step\'s title first turns it into a paragraph.\n A second Backspace moves it to the end of the previous step, or above the\n stepper if it is in the first step.\n- Emptying a step drops it. Deleting the last step removes the stepper.\n- Shift-Tab unnests inside a step and stops at the step\'s edge.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)',
},
{
projectSlug: "draggable-inline-content",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 03bd5b9292..d22ed0c87d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3753,6 +3753,107 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/06-custom-schema/14-tabs-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.5)
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
+ examples/06-custom-schema/15-stepper-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/06-custom-schema/draggable-inline-content:
dependencies:
'@blocknote/ariakit':
@@ -7171,6 +7272,28 @@ packages:
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -17491,6 +17614,31 @@ snapshots:
'@date-fns/tz@1.4.1': {}
+ '@dnd-kit/accessibility@3.1.1(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
'@emnapi/core@1.9.2':
dependencies:
'@emnapi/wasi-threads': 1.2.1