Table Built From Container Blocks
In this example, we rebuild BlockNote's table as four container blocks: table, tableRow, tableCell, and tableHeader. There is no prosemirror-tables and no special "table" content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables. The JSON shape is the same children array every other block uses.
Cells declare boundary: "sealed", which makes them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell's edge, and Enter adds another block inside the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just updateBlock with a new type. All structural operations, from adding and removing rows and columns to Tab-to-next-cell, are plain calls to the public block manipulation API: insertBlocks, removeBlocks, updateBlock, getParentBlock, and setTextCursorPosition.
Try it out:
- Press Tab / Shift-Tab to move between cells. Tab in the last cell adds a new row.
- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading.
- Hover the table to reveal the row/column controls, and watch the JSON panel update.
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 { TbTable } from "react-icons/tb";import { createTable, createTableCell, createTableHeader, createTableRow,} from "./Table";import "./styles.css";// Drop the built-in table (the one with the special `"table"` content type)// and replace it with our container-based implementation under the same// `table` type name. The specs are passed to `create` rather than `extend`,// as `extend` can only add new block types, not replace existing ones.const { table: _defaultTable, ...remainingBlockSpecs } = defaultBlockSpecs;const schema = BlockNoteSchema.create({ blockSpecs: { ...remainingBlockSpecs, table: createTable(), tableRow: createTableRow(), tableCell: createTableCell(), tableHeader: createTableHeader(), },});// Inserting a table with no explicit children seeds it from the block's// configured `children.default`: a 3-column table with a header row.const insertTable = (editor: typeof schema.BlockNoteEditor) => ({ title: "Table", subtext: "Table built from container blocks", onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: "table", }), aliases: ["table", "grid", "cells"], group: "Basic blocks", icon: <TbTable />,});type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];export default function App() { const [blocks, setBlocks] = useState<AppBlock[]>([]); const editor = useCreateBlockNote({ schema, initialContent: [ { type: "paragraph", content: "This table is built entirely from container blocks, with no special table content type.", }, { type: "table", children: [ { type: "tableRow", children: [ { type: "tableHeader", children: [{ type: "paragraph", content: "Name" }], }, { type: "tableHeader", children: [{ type: "paragraph", content: "Notes" }], }, ], }, { type: "tableRow", children: [ { type: "tableCell", children: [{ type: "paragraph", content: "Alice" }], }, { type: "tableCell", children: [ { type: "paragraph", content: "Cells hold any blocks:", }, { type: "bulletListItem", content: "lists,", }, { type: "bulletListItem", content: "headings, images…", }, ], }, ], }, { type: "tableRow", children: [ { type: "tableCell", children: [{ type: "paragraph", content: "Bob" }], }, { type: "tableCell", children: [ { type: "paragraph", content: "Tab / Shift-Tab move between cells.", }, ], }, ], }, ], }, { type: "paragraph", content: "Tab in the last cell adds a row. Press '/' to insert a new table.", }, { 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) => { // Swap the built-in Table item (which inserts the old // `tableContent` shape) for one that inserts our container // table. const defaultItems = getDefaultReactSlashMenuItems(editor).filter( (item) => item.title !== "Table", ); const lastBasicBlockIndex = defaultItems.findLastIndex( (item) => item.group === "Basic blocks", ); defaultItems.splice( lastBasicBlockIndex + 1, 0, insertTable(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 { createExtension, type Block, type BlockNoteEditor,} from "@blocknote/core";import { createReactBlockSpec } from "@blocknote/react";import "./styles.css";// A table built entirely out of container blocks, without `prosemirror-tables`// or the special `"table"` content type. A table is a container of rows, a row// is a container of cells, and a cell is a container of arbitrary blocks://// table > tableRow > tableCell / tableHeader > (any blocks)//// The JSON shape is the same `children` array every other container block// uses, and every structural operation (add/remove row or column, toggle the// header row) is a plain `insertBlocks` / `removeBlocks` / `updateBlock` call.type AnyEditor = BlockNoteEditor<any, any, any>;type AnyBlock = Block<any, any, any>;function isCellType(type: string): boolean { return type === "tableCell" || type === "tableHeader";}// ---------------------------------------------------------------------------// Cell navigation (Tab / Shift-Tab)// ---------------------------------------------------------------------------// Finds the cell / row / table the text cursor is currently inside, by// walking up the ancestor chain with `editor.getParentBlock`. Returns// undefined when the cursor isn't in a table.function getCellContext( editor: AnyEditor,): { cell: AnyBlock; row: AnyBlock; table: AnyBlock } | undefined { let current: AnyBlock | undefined = editor.getTextCursorPosition().block; while (current && !isCellType(current.type)) { current = editor.getParentBlock(current); } if (!current) { return undefined; } const row = editor.getParentBlock(current); if (!row || row.type !== "tableRow") { return undefined; } const table = editor.getParentBlock(row); if (!table || table.type !== "table") { return undefined; } return { cell: current, row, table };}// Places the cursor inside a cell. Descends through nested tables so the// cursor always lands on a block that can actually hold it.function placeCursorInCell( editor: AnyEditor, cell: AnyBlock, placement: "start" | "end",) { let target = cell; while ( target.children.length > 0 && (target.type === "table" || target.type === "tableRow" || isCellType(target.type)) ) { target = placement === "start" ? target.children[0] : target.children[target.children.length - 1]; } editor.setTextCursorPosition(target, placement);}function createRow( numColumns: number, cellType: "tableCell" | "tableHeader" = "tableCell",) { return { type: "tableRow" as const, children: Array.from({ length: numColumns }, () => ({ type: cellType })), };}// Moves the cursor to the next/previous cell, wrapping across rows. Tab past// the last cell grows the table by a row, like in a spreadsheet, with a// single `insertBlocks` call.function moveToAdjacentCell(editor: AnyEditor, direction: 1 | -1): boolean { const context = getCellContext(editor); if (!context) { // Not in a table: let BlockNote's default Tab (indent) behavior run. return false; } const { cell, row, table } = context; const rows = table.children; const rowIndex = rows.findIndex((r) => r.id === row.id); const cellIndex = row.children.findIndex((c) => c.id === cell.id); let targetRowIndex = rowIndex; let targetCellIndex = cellIndex + direction; if (targetCellIndex >= row.children.length) { targetRowIndex += 1; targetCellIndex = 0; } else if (targetCellIndex < 0) { targetRowIndex -= 1; targetCellIndex = targetRowIndex >= 0 ? rows[targetRowIndex].children.length - 1 : 0; } // Shift-Tab at the very first cell: stay put (but consume the key so the // cell's content isn't un-indented out of the table). if (targetRowIndex < 0) { return true; } // Tab at the very last cell: append a new row and move into it. if (targetRowIndex >= rows.length) { editor.insertBlocks( [createRow(row.children.length)], rows[rows.length - 1], "after", ); const updatedTable = editor.getBlock(table.id); const newRow = updatedTable?.children[updatedTable.children.length - 1]; if (newRow) { placeCursorInCell(editor, newRow.children[0], "start"); } return true; } placeCursorInCell( editor, rows[targetRowIndex].children[targetCellIndex], direction === 1 ? "start" : "end", ); return true;}// Registered on the `table` block spec, so the shortcuts are only added when// the block is in the schema. Block-spec extensions run before BlockNote's// default keyboard handlers, so Tab reaches us before the default indent.const TableKeyboardExtension = createExtension({ key: "containerTableKeyboard", keyboardShortcuts: { Tab: ({ editor }) => moveToAdjacentCell(editor, 1), "Shift-Tab": ({ editor }) => moveToAdjacentCell(editor, -1), },});// ---------------------------------------------------------------------------// Structural operations, using only the public block manipulation API// ---------------------------------------------------------------------------function getTable(editor: AnyEditor, tableId: string): AnyBlock | undefined { const table = editor.getBlock(tableId); return table?.type === "table" ? table : undefined;}export function addRow(editor: AnyEditor, tableId: string) { const table = getTable(editor, tableId); if (!table) { return; } const lastRow = table.children[table.children.length - 1]; editor.insertBlocks([createRow(lastRow.children.length)], lastRow, "after");}export function removeRow(editor: AnyEditor, tableId: string) { const table = getTable(editor, tableId); if (!table || table.children.length <= 1) { return; } editor.removeBlocks([table.children[table.children.length - 1]]);}export function addColumn(editor: AnyEditor, tableId: string) { const table = getTable(editor, tableId); if (!table) { return; } editor.transact(() => { for (const row of table.children) { const lastCell = row.children[row.children.length - 1]; // Match the row's cell kind, so a header row grows a header cell. editor.insertBlocks([{ type: lastCell.type }], lastCell, "after"); } });}export function removeColumn(editor: AnyEditor, tableId: string) { const table = getTable(editor, tableId); if (!table || table.children.some((row) => row.children.length <= 1)) { return; } editor.transact(() => { for (const row of table.children) { editor.removeBlocks([row.children[row.children.length - 1]]); } });}// Flips the first row between header cells and regular cells. Because header// cells are a distinct block type (not table metadata), this is just// `updateBlock` with a new type. Children are carried over automatically.export function toggleHeaderRow(editor: AnyEditor, tableId: string) { const table = getTable(editor, tableId); if (!table) { return; } const firstRow = table.children[0]; const allHeaders = firstRow.children.every((c) => c.type === "tableHeader"); const type = allHeaders ? "tableCell" : "tableHeader"; editor.transact(() => { for (const cell of firstRow.children) { editor.updateBlock(cell, { type }); } });}// ---------------------------------------------------------------------------// The four block specs// ---------------------------------------------------------------------------// The table itself: a container that only accepts rows. Inserting one with// no explicit children seeds it from `children.default`: a header row plus// two body rows, three columns wide.export const createTable = createReactBlockSpec( { type: "table", propSchema: {}, content: "none", children: { allow: ["tableRow"], default: [ createRow(3, "tableHeader"), createRow(3, "tableCell"), createRow(3, "tableCell"), ], }, }, { render: (props) => { // `props.block` is captured at render time; the control handlers // re-fetch the table by id so they always operate on fresh children. const { editor } = props; const tableId = props.block.id; // Keep focus (and the text selection) in the editor when clicking the // controls. const keepFocus = (event: React.MouseEvent) => event.preventDefault(); return ( <div className={"container-table"}> <div className={"container-table-rows"} ref={props.contentRef} /> <div className={"container-table-controls"} contentEditable={false} onMouseDown={keepFocus} > <button onClick={() => addRow(editor, tableId)}>+ Row</button> <button onClick={() => removeRow(editor, tableId)}>− Row</button> <button onClick={() => addColumn(editor, tableId)}>+ Col</button> <button onClick={() => removeColumn(editor, tableId)}>− Col</button> <button onClick={() => toggleHeaderRow(editor, tableId)}> Toggle header </button> </div> </div> ); }, // Recognizes pasted foreign HTML tables. parse: (el) => (el.tagName === "TABLE" ? {} : undefined), }, [TableKeyboardExtension],);// A row: only lives inside a table (`placement: "containerOnly"`), only// holds cells.export const createTableRow = createReactBlockSpec( { type: "tableRow", propSchema: {}, content: "none", children: { allow: ["tableCell", "tableHeader"] }, placement: "containerOnly", }, { // No drag handle of its own; the side menu handle falls through to the // table. meta: { draggable: false }, render: (props) => ( <div className={"container-table-row"} ref={props.contentRef} /> ), parse: (el) => (el.tagName === "TR" ? {} : undefined), },);// A cell: holds any blocks, and is `boundary: "sealed"`, so the caret and// content never implicitly cross its edge (Backspace at the start of a cell// does nothing, Delete at its end doesn't pull the next block in, arrow keys// from outside treat the table as a unit). Enter inside a cell just adds// another block to the cell.export const createTableCell = createReactBlockSpec( { type: "tableCell", propSchema: {}, content: "none", children: { allow: "any", boundary: "sealed" }, placement: "containerOnly", }, { meta: { draggable: false }, render: (props) => ( <div className={"container-table-cell"} ref={props.contentRef} /> ), parse: (el) => (el.tagName === "TD" ? {} : undefined), },);// A header cell: identical to a regular cell, but a distinct block type.// The structure itself encodes which cells are headers, instead of// `headerRows` metadata on the table.export const createTableHeader = createReactBlockSpec( { type: "tableHeader", propSchema: {}, content: "none", children: { allow: "any", boundary: "sealed" }, placement: "containerOnly", }, { meta: { draggable: false }, render: (props) => ( <div className={"container-table-cell container-table-header"} ref={props.contentRef} /> ), parse: (el) => (el.tagName === "TH" ? {} : undefined), },);.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;}/* The grid is plain CSS tables on divs. The React node-view wrappers between the regions carry `display: contents`, so the row and cell boxes end up direct children of the table box as far as layout is concerned. */.container-table { flex-grow: 1; min-width: 0;}.container-table-rows { display: table; border-collapse: collapse; width: 100%; table-layout: fixed;}.container-table-row { display: table-row;}.container-table-cell { display: table-cell; border: 1px solid #d0d0d0; padding: 4px 8px; vertical-align: top;}.container-table-header { background-color: #f3f4f6; font-weight: 600;}[data-color-scheme="dark"] .container-table-cell { border-color: #4b4b4b;}[data-color-scheme="dark"] .container-table-header { background-color: #2e2e2e;}.container-table-controls { display: flex; gap: 4px; padding-top: 4px; /* Only reveal the controls while working in the table. */ opacity: 0; transition: opacity 0.15s;}.container-table:hover .container-table-controls,.container-table:focus-within .container-table-controls { opacity: 1;}.container-table-controls button { border: 1px solid #d0d0d0; border-radius: 4px; background: none; color: inherit; font-size: 0.75rem; padding: 2px 8px; cursor: pointer;}.container-table-controls button:hover { background-color: #f3f4f6;}[data-color-scheme="dark"] .container-table-controls button { border-color: #4b4b4b;}[data-color-scheme="dark"] .container-table-controls button:hover { background-color: #2e2e2e;}