Stepper Block
Numbered steps built on the layout block API. stepper is a layout block
restricted to step children, and step is a placeable: "namedOnly"
layout block, 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.
Both blocks are layout blocks (layoutOnly: true), so they declare only
renderFrame, and the child blocks go where slotRef is attached. A step can
only be inside a stepper, so it gets no drag handle by default. The step sets
meta: { draggable: true } to keep it, so the side menu can move or delete a
step.
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:
- Enter at the end of a step adds a block to that step.
- Enter again, on the empty block, starts the next step and puts the caret in its title.
- Enter again, on the empty title, leaves the stepper. It leaves a paragraph behind, not the empty heading the step was created with.
The block's experimental_keyboard settings cannot insert a new step, 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 layout block API
cannot express that yet: placeable: "namedOnly" requires a layout block (a
block without content), and children.allow can only name layout blocks.
Empty steps stay. The stepper sets children.dissolve to false, so a
step that holds only an empty paragraph is not removed, and the stepper stays
when its last step is removed.
What the layout block 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:
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: <RiListOrdered2 />, };}export default function App() { const editor = useCreateBlockNote({ schema, initialContent: [ { type: "paragraph", content: "A stepper built on the layout block 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 ( <BlockNoteView editor={editor} slashMenu={false}> <SuggestionMenuController triggerCharacter={"/"} getItems={async (query) => filterSuggestionItems( [...getDefaultReactSlashMenuItems(editor), insertStepper(editor)], query, ) } /> </BlockNoteView> );}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. */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. * * The block's `experimental_keyboard` settings cannot insert a new step, 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 goes too. It // does not dissolve by itself (`dissolve: false`): without this, // it would stay with a new, empty step. editor.removeBlocks([stepper.children.length === 1 ? stepper : 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<any, any, any>, 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 * layout block API cannot express "a block with its own rich text that may only * appear inside a stepper": `children.allow` names layout block types only, and * a block with content is not one. */export const createStep = createReactBlockSpec( { type: "step", propSchema: {}, content: "none", layoutOnly: true, placeable: "namedOnly", }, { // A step that can only be inside a stepper gets no drag handle by // default. This one keeps it, so the side menu can move or delete it. meta: { draggable: true }, renderFrame: (props) => ( <div className="step"> {/* Chrome, not content: it sits outside `slotRef`. The number comes from a CSS counter. */} <div className="step-rail" contentEditable={false}> <span className="step-number" /> </div> <div className="step-body" ref={props.slotRef} /> </div> ), },);export const createStepper = createReactBlockSpec( { type: "stepper", propSchema: {}, content: "none", layoutOnly: true, // A step is content, not layout: an empty step stays, and the stepper // stays when its last step is removed (it gets a new, empty step). children: { allow: ["step"], min: 1, dissolve: false }, }, { renderFrame: (props) => { return ( <div className="stepper"> <div className="stepper-steps" ref={props.slotRef} /> <button type="button" className="stepper-add" contentEditable={false} onClick={() => addStep(props.editor, props.block.id)} > <span className="stepper-add-icon" aria-hidden> + </span> Add step </button> </div> ); }, }, [enterStartsNextStep],);.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; /* The centre of the first number. */ top: 33px; 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; /* The side menu of a block in a step is at the left of that block. This gap is wider than the side menu (48px), so the menu does not cover the number. */ gap: 12px; padding-bottom: 8px;}.step-rail { flex: none; width: 25px; /* Centres the number on the first line of a step's title. The title is a heading, and its first line starts 21px below its top: 18px of heading spacing and 3px of line spacing. */ padding-top: 21px;}/* 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;}/* Do not remove the top spacing of the step's title (a heading). The side menu is centred on the first line of a heading with the default spacing only. With less spacing, the menu is below the line. */.stepper-add { display: flex; align-items: center; /* The same as the gap of a step, so "Add step" aligns with the steps. */ 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);}