Block editor
Block editor is a Notion-like note editor: type / to add blocks and select text to format it. Use it for lesson notes and study pages.
Beta until design confirms the code-only parts (C52): heading 2 and 3 styles, the table toolbar, the image and maths dialogs, syntax colours and checked-item styling. The focus indicator (C5) is also open. Everything else on this page is implemented and tested.
Preview
A real editor with sample notes. Type / on an empty line for the block menu, select text for the formatting toolbar, click into the table for its toolbar, and click the formula to edit it. AI Summary, Run and image upload use demo stand-ins here.
<h1>My Notes</h1><p>A classic example is the Eames Lounge Chair. It is celebrated for its elegant looks, but every curve and material was chosen for comfort and durability. The form and function are so intertwined that they become inseparable. That synergy is often the hallmark of truly great design.</p><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>Designed to be useful or practical rather than attractive.</p></li><li data-type="taskItem" data-checked="true"><p>Read the chapter on affordances.</p></li></ul><table><tbody><tr><th><p>Principle</p></th><th><p>Question to ask</p></th><th><p>Example</p></th></tr><tr><td><p>Affordance</p></td><td><p>What does it invite me to do?</p></td><td><p>A handle invites pulling</p></td></tr><tr><td><p>Feedback</p></td><td><p>Did my action work?</p></td><td><p>A button changes colour</p></td></tr></tbody></table><pre><code class="language-python">def golden_ratio(width):
return width / 1.618</code></pre><div data-type="callout" data-emoji="💡"><div class="nu-block-callout__content">Form and function should be designed together, not one after the other.</div></div><div data-type="block-math" data-latex="\frac{a + b}{a} = \frac{a}{b} = \varphi"></div><p></p>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages and aren't published yet (D4, D12). Inside the Nuton monorepo, add them as workspace dependencies.
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactimport '@nuton/tokens/tokens.css'
import '@nuton/ui/styles.css'TipTap 3 and its table, task-list, image, maths and code extensions, KaTeX and lowlight install with @nuton/ui, pinned to exact versions (ADR 0007). Apps that never import BlockEditor don't bundle them. Maths renders as MathML, so no KaTeX stylesheet or fonts are needed.
Usage
import { BlockEditor } from '@nuton/ui'
export function LessonNotes({ saved, save }: { saved: string; save: (html: string) => void }) {
return (
<BlockEditor
aria-label="Lesson notes"
defaultValue={saved}
onChange={save}
onAiSummary={(editor) => summarise(editor.getText())}
onImageUpload={(file) => uploadToStorage(file)} // resolves with the image URL
/>
)
}- The value is HTML. The first block is the title (Figma “My Notes”); pass
withTitle={false}for notes without one. - Connect the app's services:
onAiSummary,onRunCode,onImageUploadandonFileUpload. Each feature only appears when its handler is set. - Save on change (debounced) rather than with a submit button, as people expect from note apps. Pass
namewhen it lives in a form. - Sanitize stored HTML on the server before showing it to anyone else.
- For a short formatted answer in a form, use Text editor instead.
Variants and sizes
The editor fills its column (Figma 698 wide). Content is made of blocks.
Blocks and the slash menu
| Menu item | Inserts | Source |
|---|---|---|
| AI Summary | Calls onAiSummary(editor). Shown only when you pass it. | Figma |
| Text | Paragraph | Figma |
| Header 1 · 2 · 3 | Headings (Label/X Large, Large, Medium) | Figma |
| Quote | Block quote | Figma |
| Divider | Horizontal rule | Figma |
| Bullet List · Numbered List | Lists; Tab and Shift+Tab nest | Figma |
| Checklist | Task list with real checkboxes | Figma |
| Table | 3 × 3 with a header row; a table toolbar adds and removes rows and columns | Figma (toolbar code-only) |
| Code block | Language menu, syntax colours, Copy, Delete; Run with onRunCode | Figma (colours code-only) |
| Math | LaTeX, rendered as MathML; click to edit | Figma (dialog code-only) |
| Callout / Highlight | Tinted block with an emoji | Figma |
| Image | From a web address, or uploaded with onImageUpload | Figma (dialog code-only) |
Typing after / filters the menu (“/check” → Checklist). Markdown shortcuts also work: # , ## , - , 1. , [ ] , > , ``` and ---.
Measurements
| Part | Spec |
|---|---|
| Blocks | 16 apart; body Paragraph/Small Text/Strong-950; title Label/X Large |
| Slash menu | 264 wide, radius 16, padding 8, gap 4, regular-shadow/medium; items 40 tall, radius 8, 20px icons |
| Selection toolbar | Figma Rich Editor [1.1]: text style, size, B I U S, alignment, attach, link, image, more |
| Table | 1px Stroke/Soft-200 cells (Figma 128 wide), padding 10; header Bg/Weak-50 |
| Code block | Bg/Surface-800, radius 20; header 44 with a 2X-Small button group; body padding 10 |
| Callout | State/Primary/Lighter, radius 20, padding 8 / 12, 24px emoji |
Empty notes
A new note shows the title placeholder and Figma's prompt, “Start writing or type ‘/’ to see commands”.
<BlockEditor aria-label="New lesson notes" onAiSummary={summarise} />States and interactions
Type /
Select text
Cursor in a table
Read-only
<BlockEditor aria-label="Shared notes" disabled defaultValue={sharedHtml} />Code-only additions (C52, pending design)
- Figma shows the blocks but not their editing tools. Code adds the table toolbar, the image and maths dialogs, syntax colours, a checked-item style (struck through) and code output.
- Header 2 and Header 3 use Label/Large and Label/Medium; Figma only shows Header 1 (Label/X Large).
- Block drag handles aren't included: TipTap's drag-handle extension needs its collaboration packages.
Composition examples
Connecting your services
<BlockEditor
aria-label="Lesson notes"
defaultValue={note.html}
onChange={debounce(saveNote, 800)}
// AI Summary: read the text, insert the result as a callout
onAiSummary={async (editor) => {
const summary = await api.summarise(editor.getText())
editor.chain().focus().insertContent({ type: 'callout', attrs: { emoji: '✨' }, content: [{ type: 'text', text: summary }] }).run()
}}
// Run: return output to show it under the code block
onRunCode={(code, language) => api.runCode({ code, language })}
onImageUpload={(file) => storage.upload(file).then((r) => r.url)}
onFileUpload={(file) => storage.upload(file).then((r) => ({ url: r.url, name: file.name }))}
/>API reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | — | Initial HTML. |
value | string | — | Controlled HTML. Replaces the document when it differs (not re-emitted). |
onChange | (html: string) => void | — | Called after every change; '' when empty. |
withTitle | boolean | true | The first block is a title (Figma “My Notes”). |
name | string | — | Adds a hidden input with the HTML for form submission. |
disabled | boolean | from Field | Read-only: no menus or toolbars. |
aria-label | string | — | Accessible name when there is no Field. |
labels | Partial<BlockEditorLabels> | BLOCK_EDITOR_LABELS (English) | Every string: placeholders, menu items, toolbar and dialog labels. |
onAiSummary | (editor: Editor) => void | — | Shows “AI Summary” at the top of the slash menu. |
onRunCode | (code, language) => void | string | Promise<…> | — | Shows Run on code blocks. A returned string is shown as output. |
onImageUpload | (file: File) => Promise<string> | — | Adds “Upload from device” to the image dialog; resolve with the URL. |
onFileUpload | (file: File) => Promise<{ url, name }> | — | Shows the attach button in the selection toolbar; inserts a link. |
defaultCodeLanguage | string | 'python' | Language of new code blocks (Figma shows Python). |
onEditorReady | (editor: Editor) => void | — | Receives the TipTap editor. |
ref | Ref<HTMLDivElement> | — | Forwarded to the root. |
Also exported: BLOCK_EDITOR_LABELS, BLOCK_EDITOR_LANGUAGES (code languages), BLOCK_EDITOR_FONT_SIZES and filterBlockCommands.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| / | Opens the block menu at the cursor |
| ↑ / ↓ | Move through the menu; Enter or Tab inserts; Escape closes |
| ⌘/Ctrl / B | Bold (I, U, Shift+S for italic, underline, strikethrough) |
| Tab / Shift + Tab | Indent and outdent list items and checklist items |
| ⌘/Ctrl / Z | Undo (Shift+Z to redo) |
| Alt / → ← | With a picture selected, makes it bigger or smaller (10% a step, never wider than the column). Dragging a corner does the same. |
Screen readers
The editor is a multi-line textbox. While the block menu is open, the textbox points at the highlighted option (aria-activedescendant), so the choice is announced as you arrow. Checklist items are real checkboxes; formulas render as MathML.
Focus
Focus stays in the text while you use the slash menu. Toolbar and dialog choices return focus to the text.
Contrast and colour
Text Strong-950 (17.93:1), placeholder Sub-600 (6.69:1). The empty title placeholder uses Disabled-500 (4.23:1; Figma Disabled-300 was 1.53:1, C8). Code text is white on Surface-800 (15.13:1); the language label is Soft-400 (6.07:1).
Touch and pointer
Menu items are 40px tall; toolbar items 28px. Checkboxes are 16px inside a 20px row, the Figma size.
You must: give the editor a name (Field label or aria-label), ask for alt text when people add images, and translate labels outside English.
Figma mapping
| Figma | Code |
|---|---|
| Container (423:20929) · Default / Variant2 | Empty-line prompt; the “/” chip and menu |
| Dropdown Items (423:20741) | Slash menu: groups, items, AI Summary |
| Rich Editor [1.1] over the selection | Selection toolbar |
| My Notes (Label/X Large) | Title block (withTitle) |
| Checkbox Label [1.1] | Checklist items |
| Table, code, callout and image frames | Table, code block, callout and image blocks |
Guidelines and related components
Use Block editor for
- Personal notes on a lesson
- Study pages that mix text, tables, code and maths
Don't use Block editor for
- A formatted answer inside a form (use Text editor)
- Plain text (use a textarea)
Do and don't
<BlockEditor onImageUpload={upload} /><BlockEditor onRunCode={() => {}} />