ExamplesCustom SchemasTabs Block

Tabs Block

A tab set built on the layout block API. tabs is a layout block whose children are restricted to tab panels, and tab is a placeable: "namedOnly" layout block, so a panel can only ever exist inside a tab set — the schema enforces both. Both blocks are layout blocks (layoutOnly: true), so they declare only renderFrame, and the child blocks go where slotRef is attached.

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 layout block 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.

Empty tabs stay. The tab set sets children.dissolve to false. A tab is content, not layout: an empty tab is still a tab, and when the last tab is removed, the tab set stays with a new, empty tab. (A column list dissolves: an empty column is removed, and one column left is just its blocks.)

Relevant Docs:

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: <RiLayoutTopLine />,  };}export default function App() {  const editor = useCreateBlockNote({    schema,    initialContent: [      {        type: "paragraph",        content:          "Tabs built on the layout block 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 (    <BlockNoteView editor={editor} slashMenu={false}>      <SuggestionMenuController        triggerCharacter={"/"}        getItems={async (query) =>          filterSuggestionItems(            [...getDefaultReactSlashMenuItems(editor), insertTabs(editor)],            query,          )        }      />    </BlockNoteView>  );}