Text Edit
A Notion-style Tiptap markdown editor in body typography — slash and bubble menus, GFM tables, block drag handles, markdown shortcuts and an onCommit contract.
- Status
- Since
0.1.0- Accessibility pattern
- multiline textbox + listbox slash menu
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Text Edit before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify text-editInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/text-editRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/checkbox, @vegastack/code-block, @vegastack/command, @vegastack/copy-button, @vegastack/drag-item, @vegastack/dropdown-menu, @vegastack/file-kind, @vegastack/file-viewer, @vegastack/inline-chip, @vegastack/input, @vegastack/markdown-view, @vegastack/popover, @vegastack/text-anchor, @vegastack/text-anchor-doc, @vegastack/toggle, @vegastack/tooltip, @vegastack/use-file-drop, @vegastack/code-highlight, @vegastack/alert, @vegastack/dropzone, @vegastack/empty, @vegastack/tabs, @vegastack/toast, @vegastack/suggestion-menu, @vegastack/text-edit-grammar, @vegastack/text-edit-schema, @vegastack/text-edit-markdown, @vegastack/avatar, @vegastack/embed-providers, @vegastack/sheet, @vegastack/separator, @vegastack/use-announcer, @vegastack/emoji-data, @vegastack/block-view, @vegastack/person-avatar, @vegastack/task-inline, @vegastack/block-toolbar, @vegastack/use-platform.
It also adds the sanctioned engines to your package.json: @tiptap/react, @tiptap/pm, @tiptap/extensions, @tiptap/suggestion, @tiptap/extension-collaboration, @tiptap/extension-collaboration-caret, @tiptap/extension-find-and-replace, @tiptap/y-tiptap, yjs, y-protocols.
Usage
Pasting unsupported files leaves selected text and selection intact. Upload-enabled hosts replace the selection only when at least one pasted file can be handled.
import { TextEdit } from "@/components/ui/text-edit";
const [html, setHtml] = useState("<p>Hello</p>");
<TextEdit
value={html}
onValueChange={setHtml}
placeholder="Add a description…"
aria-label="Body"
aria-describedby="body-help"
/>;TextEdit is controlled with value / onValueChange (HTML strings), or uncontrolled with
defaultValue. It is built on Tiptap v3 StarterKit, which bundles undo/redo
history, the bold / italic / strike / code marks, headings, bullet & ordered lists,
blockquote, and markdown-ish input rules (type **bold**, # Heading, - for a list,
> for a quote, `code`).
// Uncontrolled — seed initial content and read changes via onValueChange.
<TextEdit
defaultValue="<p>Draft…</p>"
onValueChange={(html) => save(html)}
aria-label="Notes"
/>Light first paint
The editor is heavy (Tiptap and ProseMirror, ~180 KB gzipped), so TextEdit does not ship it in the
page's first bundle. It renders the saved value as plain read HTML first — the same
MarkdownView tree, in the editor's exact classes, on the server
too — and loads the editor on intent: a hover or touch starts the download, and a click, tap or
focus swaps the editor in place before the next paint. The caret lands where the click did, a click
on a task checkbox toggles it, and anything typed while the editor loads is kept. The swap is
invisible: the read view is contenteditable with the editor's role, label and focus cue, so it
looks, focuses and opens the keyboard exactly like the editor. readOnly and disabled never load
the editor at all.
citation is MarkdownView's: in the read view a [[n]]
marker renders as a superscript citation, and pressing or focusing one is the citation's — it
never starts editing. In the editor the markers are plain [[n]] text.
Once the editor has loaded on a page, every TextEdit mounted afterwards swaps it in straight
away. To load it ahead of a first edit — a "New task" dialog about to open — call
preloadTextEdit():
import { preloadTextEdit } from "@/components/ui/text-edit";
<Button onPointerEnter={() => void preloadTextEdit()} onClick={openDialog}>
New task
</Button>;Scope
TextEdit is the presentational base editor — a controlled HTML value, the StarterKit
formatting set, the slash and bubble menus, the placeholder, read-only mode, a Cmd/Ctrl+Enter onSubmit
affordance, and minHeight/maxHeight sizing. Heavier, app-coupled capabilities are intentionally
out of this core (each needs app infrastructure or ships as a separate composed extension),
mirroring the system's presentational / app-coupled rule:
| Capability | Why it's out of the base | Where it lives |
|---|---|---|
| Image upload / paste-to-upload | Needs app storage (R2/CDN) | App resolves URLs (same split as Image) |
| @mentions | Needs the app's user/entity data | App-supplied data source + query |
| Emoji picker, syntax highlighting | Composed Tiptap extensions | Future composed addons |
| Callouts | No clean CommonMark/GFM form to round-trip to | A quote, or a MarkdownView-only extension |
| Real-time collaboration (multi-cursor, awareness, Yjs CRDT) | Needs @tiptap/extension-collaboration + yjs + a sync backend | Separate text-edit-collab component |
This base ships no @tiptap/extension-collaboration and no yjs dependency — it is a self-contained
single-user editor. For richer needs, compose the relevant extension on top or use the dedicated
follow-up component.
Prose styling is not this component's to own: the editor surface wears the shared prose
recipe from @vegastack/design, the same string MarkdownView puts on its root, so what a person
types here and what the app renders from markdown are one typography.
Anatomy
TextEdit is the one component you use. It renders a light read view first and lazily loads
TextEditEditor (its own file, text-edit-editor.tsx, never imported directly) in its place:
Inside, it composes these pieces:
// data-slot="text-edit" data-variant — document: no border, no ring, no fill; boxed: a bordered field
<TextEdit>
// Before the editor loads: data-slot="text-edit-read" — the saved value as MarkdownView, with the
// editor's classes, role, label and focus cue, contenteditable while editable
<MarkdownView />
// Once loaded (text-edit-editor.tsx), in its place:
// Tiptap contenteditable, role="textbox" aria-multiline data-focus-cue="caret" (boxed: "border")
<EditorContent />
// children — boxed only: inside the box, after the document (a composer's actions row)
// In a <body> portal:
// data-slot="text-edit-bubble-menu" — role="toolbar", over a selection
// (or text-edit-link / text-edit-image / text-edit-turn-into while a panel is open)
<BubbleMenu />
// data-slot="suggestion-menu" data-menu="text-edit-slash" — role="listbox", while typing after /
// (data-menu="text-edit-mention" while typing after @)
<SuggestionMenu />
// data-slot="text-edit-block-handle" — pointer-only drag handle beside the hovered block
<BlockHandle />
// over the hovered table: data-slot="text-edit-table-grip" (data-axis row | col | table) —
// buttons that open a DropdownMenu and drag to reorder; data-slot="text-edit-table-add" — "+" bars
<TableControls />
</TextEdit>- Root (
data-slot="text-edit",data-variant) — indocumentmode transparent and borderless: it takes the surface it sits on. Inboxedmode a bordered field whose border darkens subtly while the editor holds focus and turns destructive when invalid. Carriesdata-editableanddata-invalidwhen it wraps anaria-invalidcontrol. Clicking its blank space focuses the end of the document. - Bubble menu (
data-slot="text-edit-bubble-menu",role="toolbar") — a Turn into button,Togglebuttons for bold, italic, strikethrough, inline code and link, Clear formatting, and Comment last when the host takes comments; link swaps it for an inline URL input with Open and Remove (data-slot="text-edit-link"). With the caret in a link, a link card (data-slot="text-edit-link-card") shows where it goes (a click opens it), Edit and Remove. Link and media panels open under the caret's line. It opens only over a selection that holds text — select all in an empty editor shows nothing — and it stays on its selection while any scroller around the editor scrolls, hiding while the selection is scrolled out of view. - Block handle (
data-slot="text-edit-block-handle") — a pointer drag handle; the keyboard has the same moves. - Table controls — for the hovered table only: row, column and corner grips
(
data-slot="text-edit-table-grip",data-axis="row" | "col" | "table"), each a labelled button that opens aDropdownMenuand drags to reorder (the corner moves the whole table, where every block's handle sits), and the "+" bars (data-slot="text-edit-table-add") that add a row or a column at the end. - Slash and mention menus (
data-slot="suggestion-menu",data-menu="text-edit-slash"or"text-edit-mention",role="listbox") — the sharedSuggestionMenuthatTemplateEditor's picker also opens: rows exactly likeComboboxItem's (theaccenthighlight, muted icons, muted trailing text), group headings in the mention menu, keyboard hints in the slash menu. It sits under the caret, flips above when below is short, and never takes focus — the caret never moves. - Read view (
data-slot="text-edit-read",role="textbox") — the saved document asMarkdownViewin the editor surface's classes, until the editor loads; see Light first paint. - Editor surface (
role="textbox",aria-multiline) — the Tiptap/ProseMirror contenteditable. Prose is styled entirely with semantic tokens (headings, lists, code, blockquote), so it tracks the active theme with no@tailwindcss/typographydependency. It has no fill, border or ring in any state — indocumentmode the caret is the focus cue, inboxedmode the root's border — and nothing in it widens the page.
Examples
Slash menu, bubble menu and in-place editing
There is no toolbar. Type / for a filterable block menu under the headings Text
(text, headings 1–4), Lists (bulleted, numbered, checklist), Blocks (quote, callout, toggle,
divider, code block, diagram, table), Media (image, video, audio, file) and Insert (a link, then the
host's own items); a filtered list drops the headings. slashCommands limits it.
Pasting from Notion keeps its to-dos as a checklist and its callouts as callouts (its HTML, and its Markdown's <aside>). Select text for a bubble menu with Turn into, bold, italic, strikethrough, inline code, link and
clear formatting. Every menu floats in a portal on <body> and flips to stay in view, so no
overflow container clips it. The editor has no view mode: leaving it calls onCommit when the text
changed, Esc reverts (onRevert), and autosave commits after an idle gap. Every
command, the shortcuts, the input rules and a view/edit parity example are on the
Markdown guide.
Escape never erases saved text. escapeBehavior decides what Esc does: "revert"
restores the document as focus found it, blurs and calls onRevert — a field you edit and then
confirm; "blur" keeps the text, commits it and blurs — a document that saves as you type. It
defaults to "blur" whenever autosave is set and to "revert" otherwise, so a page that
autosaves can never restore text older than what it already saved. A host that saves on its own
schedule (from onValueChange, say) calls handleRef.current.markSaved() after each acknowledged
save: the saved document becomes the baseline Escape reverts to and a commit compares against.
A smaller slash menu
Bubble menu
Every element
Headings, marks, links, the three lists (nested), quotes, fenced code with a language selector,
rules, GFM tables and images, rendered by MarkdownView above and edited below. Each round-trips to
the same markdown.
Tables
Notion's simple table: a rounded outer border, a rule between every cell, and a header row on a
muted ground at medium weight — the same styles in the read view (MarkdownView) and the editor.
There is no toolbar; everything is on hover, for the hovered table only:
- Row grip (⠿ on the row's inner left edge) — click to select the row and open its menu: Insert above, Insert below, Move up, Move down, Duplicate, Clear contents, Delete row. Drag it to reorder rows.
- Column grip (on the column's inner top edge) — Insert left, Insert right, Move left, Move right, Duplicate, Clear contents, Delete column. Drag it to reorder columns.
- Corner grip (where every block's handle sits) — selects the whole table: Clear contents, Delete table; drag it to move the table. A fully selected table also deletes with Backspace or Delete.
- "+" bars below and beside the table add a row or a column at the end.
- Column borders drag to resize (
col-resizecursor, a thin line on the border). A column's width is its own, as in Notion: the first resize, and every column added (160px), stores each column's width, so the others never give way and a table wider than its box scrolls sideways inside it. Markdown has no column widths, so informat="markdown"a width lasts for the session; HTML keeps it. - After a last table (or a last audio, video or image block) there is room to click under it, and
clicking there, ↓ from the table's last row, or ↓ / → on the selected block adds an empty paragraph
to type in. A comment box (
variant="composer") shows no scrollbar after a mention.
Tab / Shift Tab move between cells and
Tab in the last cell adds a row; Enter stays in the cell;
/table inserts a 3×3 table with a header row. The header row always stays
first — GFM has no other place for it — and saves as GFM markdown that loads
back identically.
| Owner | Task | Due | | ----- | -------------- | ------ | | Ada | Release notes | Friday | | Grace | Pricing page | Monday | | Linus | Legal review | Today |
HTML tables
With format="html" the corner, first-row and first-column menus also toggle the Header row and
Header column (markdown always has exactly one header row, so the toggles appear only here).
Beside it, the same table in MarkdownView.
Block handles
The ⋮⋮ handle beside the hovered block drags it (or a single list item) to a new place; a click on
it (no drag), or Mod /, Shift F10 or the context-menu key for the caret's block, opens
the block menu: Turn into ▸ (the block types, Callout ▸ by tone, Toggle), Duplicate
(Mod D), Copy link (headings, with headingLinkHref), Copy as Markdown,
Move up / Move down, Open by default (toggles) and Delete. On a touch screen the handle
follows the caret's block (24px, a 44px hit area), a tap opens the menu as a bottom sheet with 44px
rows, and a drag starts after a 350ms hold. Near the window's top or bottom edge a drag scrolls the
page. In a shared document the dragged block is held by its Yjs position: if a collaborator removes it
mid-drag, the drop is skipped with a toast instead of moving the wrong block.
Mod Shift ↑ / ↓ does the same from
the keyboard. A table's handle is its corner grip (see Tables).
dragHandles=false turns the handle and all the table's hover controls off.
The handle is a 16px grip 4px before the text, never closer than 6px to the edge of its frame (a
Dialog, Sheet, Popover or Card). Where the frame's padding is too narrow for that — a Dialog's 16px —
the content takes just enough start padding (measured, so it follows the frame) for the grip to
sit clear of the text and the caret; the read view the editor replaces carries the same padding, so
the swap never moves the text. On a page with room the text keeps its column, and a boxed editor
— its own frame — holds the grip just inside its border.
Where the editor must line up with a field above it — a New task dialog's description under a ghost
Input or Textarea title — pass inset="none": no start padding, so the text, the placeholder and
the caret start where the title does, and the block grip (which would have no room) is not shown.
The block menu still opens from Mod /, Shift F10 or a
touch long-press; Mod Shift ↑ / ↓ still move blocks;
and a table keeps its own grips.
Find, emoji, heading links, word count and host slash items
- Find and replace (
findReplace): Mod F inside the editor opens a find bar that floats over the top of the text at its end (role="search", belowfloatingSafeArea.top) — the document never moves when it opens — with the selection's text as the term. It offers "3 of 12", previous / next (Enter, Shift Enter, Mod G), match case, whole word and regular expression (RE2, so no pattern can run away). While editable it also has a Replace row; Replace all is one undo step. A match inside a closed toggle opens it for this viewer. Esc closes the bar with the caret back where it was. Outside the editor, the browser's own find runs.handleRef.openFind()opens it from a menu ("Find in page" on a phone). Matches inside mention chips are not searched. - Emoji (
emoji)::and two or more letters, at the start of a block or after a space or(, opens a menu of up to 8 matches fromemoji-data, loaded on first use. The pick lands as the character itself.10:30,Note:andhttps://never open it. - Heading links (
headingLinkHref): a#button sits in the gutter just before the hovered heading's first line, or the caret's (after the heading's text on a coarse pointer), even read-only, and copies the link with a "Link copied" toast; the text never moves. The block grip steps further out beside it (and past a section marker), as it does for a list's bullet. Build it from the anchor's slug and block id (#<slug>~<id>).scrollToHeadingtakes that same<slug>~<id>and finds the heading by block id first, so a renamed heading still resolves, then by slug. It opens closed toggles around the heading and returns whether it found it. - Word count (
onStatsChange,handleRef.getStats()): words (runs of non-space characters), characters, reading minutes at 230 words a minute, and the selection's words. The counts are reported when idle after a change, never per keystroke. - Host slash items (
slashActions): your own/menu rows after the built-in ones, matched by label andkeywords.onSelectgetsinsertMention, the same chip the@menu inserts, andinsertContent(json)for a block. Adisableditem shows itsdisabledReasonand cannot be chosen.
Counting…
<TextEdit
format="markdown"
collaboration={collaboration}
findReplace
emoji
headingLinkHref={({ slug, id }) =>
`/library/${pageId}#${slug}${id ? `~${id}` : ""}`
}
onStatsChange={setStats}
toggleState={toggles}
slashActions={[
{
id: "page",
label: "Page",
keywords: ["subpage"],
icon: <FilePlus />,
onSelect: async ({ insertMention }) => {
const page = await createSubPage(pageId);
insertMention({ kind: "page", id: page.id, label: page.title });
},
},
]}
/>Linked task lines and section marks
- Linked task lines: a checklist line whose first inline is a task mention,
- [x] [@Order glass](mention://task/t1), is linked to that task.linkedTaskId(node)returns its id, ornullfor any other node. Its box is the task's done state.onTaskItemToggle(taskId, checked)reports only this person's own ticks, including their undo. It never reports a collaborator's tick, avaluechange or the host's ownsetTaskItemChecked. - Make task (
onMakeTask): the block menu of an unlinked checklist line offers Make task, or Make N tasks when the selection reaches several unlinked lines (open it with ⇧F10, or the grip with the lines selected). It callsonMakeTaskwith each line'skey(itsblockId, given one if it has none) and trimmed text. Create the tasks, then callhandleRef.linkTaskItems([{ key, id, label }])to put a task chip at the start of each line. The key follows the line through any edits made meanwhile, a collaborator's included; a line removed, edited or already linked by then is skipped. If the line starts with the title as plain text, the text after the title stays after the chip ("Order glass — by Friday"); otherwise the whole line stays after the chip, mentions included. Nested lines stay. Linking is one undo step. - One box per task (
onTaskOpen): a linked line draws as aTaskInline— the line's own box, round, is the task's done state; the task chip becomes the title, which opens the task on a click (or Alt+Enter with the caret on the line):onTaskOpen(taskId), the host's peek. The assignee's avatar and a due chip follow the title (mentionStatus'sassignee,dueandoverdue, else theInlineChipProvider'sstate), and ↗ to the task's page (mentionHref) shows on hover. The read view andMarkdownViewtake the same prop. Without it the line is a checkbox and a task chip, as before. - → Task: with
onMakeTask, the pointer resting on an unlinked checklist line shows → Task at the end of its text — Make task, one click from the line.onMakeTask(items, { anchor })hands over the first line's element: point aMakeTaskPopoverat it instead of opening a modal. - The task changed elsewhere:
handleRef.setTaskItemChecked(taskId, checked)ticks or unticks every line linked to it. It is not an undo step and returns how many lines changed. - Closed targets (
mentionStatus(kind, id)): return{ struck: true, status: "Canceled" }to strike a chip's label through and add a small status tag. LikementionLabel, it is read at render, and chips re-render when the function changes. The read view andMarkdownViewtake the same prop. - Section marks (
sectionMarks): each{ key, tone, label }names a heading by itsblockId. It draws a dot in the gutter beside the heading, 32px before the text (50px on touch), clear of the drag grip, and awarningorinfobar along the section, up to the next#–###heading, whatever its level (sections never nest; a####is a block of its section). Leave 44px of start gutter for them (62px on touch). Marks mount the editor at once, so a read-only document shows them too. They are decorations: nothing is written to the document and no text moves. Typing inside a block maps them; they are rebuilt when the marks change or an edit adds, removes, replaces or retypes a top-level block. The dot is a button named bylabel(also its tooltip) that callsonSectionMark(key). Headings carry ablockIdin a shared document or in HTML withdata-blockid, not in plain Markdown.
<TextEdit
format="markdown"
collaboration={collaboration}
handleRef={handle}
mentionStatus={(kind, id) =>
kind === "task" && closed(id) ? { struck: true, status: label(id) } : null
}
onMakeTask={(items, { anchor }) => openMakeTaskPopover(items, anchor)} // then handle.current.linkTaskItems(links)
onTaskOpen={(taskId) => openTaskPeek(taskId)}
onTaskItemToggle={(taskId, checked) => setTaskDone(taskId, checked)}
sectionMarks={[
{ key: heading.blockId, tone: "warning", label: "May be out of date" },
]}
onSectionMark={(key) => openSectionChanges(key)}
/>Placeholders
Keep placeholder to the recommended copy — "Add a description…", "Add a summary…", "Add a
comment…". Focused and still empty, it becomes the "Type / for commands" hint.
Document and boxed
variant picks the editor's chrome and focus cue. document, the default, is the Notion-style page
editor for a task description or a meeting summary: no border and no fill in any state, and the
caret is the focus cue — every other example on this page. boxed frames the editor in a bordered
field for a comment composer or a form field: the border darkens subtly (border-ring/50, easing in
over 150ms) while the editor holds focus, nothing fills, and a click anywhere in the box starts
editing. children render inside the box after the document — a composer's actions row.
Boxed
Boxed, invalid
A boxed editor marked aria-invalid keeps its destructive border while focused; focus never
replaces it.
A comment is required before you can post.
Composer
variant="composer" is a comment box: at rest it is exactly an Input — 32px, its border,
radius, padding, type size and focus cue — and it grows line by line with the text to about ten
lines, then scrolls inside, with no jump on focus. actions sit at the end of the first line,
centred on it, and stay pinned bottom-right as the text wraps; footer renders inside the box
under the text (a draft's file cards). CommentComposer and replies are built on it.
Document type scale
typeScale="document" sets a page's text the way it is read (TYP-20): 14/24 on a fine pointer,
16/26 on a coarse pointer or a narrow window, 12px between paragraphs, # 24 · ## 20 · ### 16
with more room above a heading than below, and inline code in the body's ink. The read view and the
editor wear the same metrics — on a touch screen the editor is 16px anyway (no iOS zoom) — so the
swap from reading into editing moves nothing. Place it in a
DocumentLayout for the text column. MarkdownView takes
the same typeScale.
<TextEdit format="markdown" typeScale="document" defaultValue={page.body} />Editing patterns
An empty editor with a faint placeholder ("Add a description…"; focused, it reads "Type / for commands"), a readOnly
editor for rendering stored content — a document (role="document"), not a field — and a disabled
one (dimmed, aria-disabled).
Markdown
format="markdown" makes value, defaultValue, onValueChange and onSubmit carry Markdown
instead of HTML, through Tiptap's own @tiptap/markdown extension: headings, bold, italic,
strikethrough, links, bullet, ordered and task lists, blockquotes, code, tables and images
round-trip unchanged — and so do mentions, callouts, toggles and file links. Loading a
document never fires onValueChange; only an edit does. Store what the editor emits and render it
elsewhere with MarkdownView. The format is read once, when the
editor is created.
## Release notes - **Faster** search - A new [changelog](https://design.vegastack.com/docs/changelog)
Mentions
mentions={{ kinds, search }} turns on @: typed after a space, an opening bracket or at the start
of a line it opens a menu grouped by kind, at most five each. The design system knows two kinds,
user and agent; every other kind is the app's, and kinds names each one the menu offers —
{ id, noun, plural, icon?, order }, its group heading the plural, the groups in order (ties
in array order). A kind's id is any [a-z][a-z0-9_]{1,31}, so [@Linear](mention://subfamily/s1)
reads and writes like a built-in. An option with a group ("Recent") shows under that heading
instead, every such group first, in the order its options arrive. Give the same kinds to the
InlineChipProvider's kinds so every chip draws the kind's icon and noun. The query may hold spaces — @Asha Rao searches the whole name — and two
spaces in a row, or a phrase that matches no one past a space, close the menu. search runs at
once when the menu opens, then 120ms after the last keystroke, with an AbortSignal that aborts
when the query changes. The menu opens on placeholder rows in its rows' geometry, as tall as its
answer usually is, and holds its height while a search is in flight, keeping the rows that still
match; an answer is kept per query, so a query asked before shows at once (and is asked again
after 30 seconds). It says "No matches" or "Couldn't search" when there is nothing to pick.
↑ ↓ move, Enter or Tab pick,
Esc closes. A pick becomes a chip, stored as [@Asha Rao](mention://user/u1), that Backspace
removes whole. mentionHref(kind, id) makes a chip open its target on
Cmd/Ctrl-click (and link it in the read view and MarkdownView); a
person never links, and an id starting with restricted: is a muted chip for a
target the reader may not open — the app keeps the real id after the colon so an
edit never loses the mention.
Bookmarks
bookmarks turns on a link shown as a card: the page's title, a line about it when the host has
one, and its icon with the site's address — Bookmark in the slash menu (the new card asks for
the address: Enter makes it, Escape removes it) and in the paste popover. The preview is the
InlineChipProvider's linkPreview — the app's server reads the page, so the reader's browser
never contacts the site — with the optional siteName, description and image fields; nothing
loads from the site itself and nothing plays (live embeds stay embeds, for allow-listed video
hosts). While editing, a click selects the card and Cmd/Ctrl-click opens the page.
A bookmark is stored as [url](url "bookmark") on its own line — a link to any other Markdown
reader — and MarkdownView draws it as the same card (MarkdownBookmark, which also takes
preview or fetchPreview directly). Without bookmarks, a bookmark already in the document
still shows as a card.
Editing ergonomics
- Leaving a code block: Mod Enter, or ↓ on its last line, puts
the caret on the line below — a new paragraph when nothing follows — and a footer under the block
says so ("⌘↵ to exit") while the caret is in it. A composer with
onSubmitkeeps sending on Mod Enter. - Block chrome: a code block's wrap and copy, an image's Replace, Caption, Alt text and Open
(Download and Copy link in ⋯), and a diagram's expand and actions all sit in a
BlockToolbar: the top-right corner, 28px buttons, four at most. - The block grip is still a 16×20 glyph, with a 24×28 hit area around it.
touchBlockMenu="longPress"drops the grip on a coarse pointer: half a second held still on a block opens its menu as a bottom sheet. - Upload progress:
onImageUploadandonFileUploadgetonProgress(0…1). Call it and the placeholder shows how far along it is — the picture itself, dimmed in its own proportions, with a bar along its foot; a file chip with a ring — instead of a spinner. The insert panel closes the moment a file is picked. - The selection bubble stays inside the text column (never over an outline rail or a comment
margin beside it), wrapping onto a second row when the column is narrower. With
onMakeTask, a selection reaching unlinked checklist lines offers Make task / Make N tasks, andbubbleActions({ text, close })adds the host's own actions ("Ask agent…") before Comment.toolbar="dock"puts the selection's formatting in a 44px bar along the bottom of the window on a coarse pointer, above the on-screen keyboard.
Roomy menus and phone sheets
menuSize="lg" gives the slash and mention menus room: 320px wide and up to min(60vh, 440px)
tall, each block with a line saying what it is ("Big section heading"), and the full slash menu
grouped Basic · the host's items · Media · Advanced, so the host's own blocks
(slashActions, under their group — "Insert" unless named) are one screen away instead of at the
bottom. Filtered, the menu drops its headings as before.
mentions.sections fixes the @ menu's order whatever the query matches best: ["people", "agents", "records"] lists People, then Agents (the agent kind, drawn with the agent avatar),
then each record kind under its plural, the nearest match first. A person's description (an
email) shows only on the highlighted row, unless two people in the menu share the name; an
option's descriptionShown: "always" keeps a note in view ("Can't see this page").
menuPresentation="auto" turns both menus into a bottom Sheet on a coarse pointer or a window
under 640px: a search field takes the keyboard (↑ ↓ Enter
Esc) over 44px rows, and a pick lands at the caret the / or @ was
typed at. On a fine pointer it is the caret menu. Both props default to today's
240/288px caret menus.
Pasting links
A lone http(s) link pasted at the caret (not over a selection, not in code, not pasted as plain text) lands as its URL's text, then becomes what it names, as in Notion and Google Docs:
- One of the app's own records —
mentions.fromUrl(url, { signal })returns a promise for the record (ornullat once for another site's link). The chip replaces the URL when it answers, unless the text was edited meanwhile; an answer ofnullleaves a link. - A YouTube or Loom video, with
embeds: an embed where it can stand in for an empty paragraph, else a link (see Embeds). - Anything else: a link. With the
InlineChipProvider'slinkPreview, its text is the page's title when that answers within 1.5 seconds (the URL waits as text until then, and the link and its title land as one step); the link card shows the page's icon and title.
With bookmarks, a link pasted where a block can stand (alone on its line, in the document, a
quote, a callout or a toggle) also offers Bookmark — a card (see Bookmarks).
Where there is a second choice, a small popover under the paste offers it — Mention · Link, Embed · Link, Link · Bookmark or Embed · Link · Bookmark, the one in effect pressed — until the next keystroke, a click or Escape; Tab moves into it. Each step is an undo step of its own, so undo right after a paste leaves the URL as text, in a shared document too. Leaving the text before an answer comes leaves a link, and a late answer changes nothing.
Ask [@Asha Rao](mention://user/u1) to check [@Breaker sizing](mention://page/p2) before [@Wire the east panel](mention://task/t1). Type @ to mention someone.
<TextEdit
format="markdown"
defaultValue={page.body}
mentions={{
kinds: [
{ id: "user", noun: "Person", plural: "People", order: 0 },
{
id: "page",
noun: "Page",
plural: "Pages",
icon: <FileText />,
order: 1,
},
],
search: (q, { signal }) => searchMentions(q, signal),
}}
mentionHref={(kind, id) => (kind === "page" ? `/library/${id}` : null)}
/>Collaboration
collaboration={{ document, provider, user, peer }} edits a shared Yjs document live with others.
The app owns the connection (Hocuspocus, or any provider with an awareness); the design system
types it structurally and never imports one. Each collaborator's caret and selection draw in their
tag hue with their name above the caret for two seconds after they move (an agent always in
purple), and ⌘Z undoes only this viewer's own changes. A host that replaces the text under the
reader (a version restored) calls handleRef.current.clearHistory(): undoing the reader's earlier
changes onto the new text would splice the old text into it. Who a caret belongs to comes only from
peer(clientId) — the collaboration server's authenticated identity for that connection — never
from the name a client writes into awareness, so nobody can draw a caret as someone else; without
peer a caret is a plain grey bar. The document is the truth: value, onValueChange,
onCommit, onRevert, autosave and markSaved are ignored, nothing is serialized per keystroke,
and Escape blurs. The editor mounts at once — readOnly too, so a viewer watches the text and carets
move (readCopy shows the stored Markdown until the editor is in).
Two choices keep one person's view from becoming everyone's edit: a toggle's chevron opens and
closes it for this viewer only ("Open by default", in the block menu, sets it for everyone;
toggleState lets the host keep each viewer's choice across reloads, and a find result, heading
link, comment or the caret inside a closed toggle opens it without recording that as a choice),
and a page, task or file mention is stored without its title — each reader's chip asks
mentionLabel(kind, id), so nobody sees a title they may not open (a pasted or imported mention
loses its title too). A comment highlight made here carries anchor.y, Yjs positions bound to its
first and last characters: it keeps its words whoever edits around or inside it, is orphaned once
they are all deleted, and onAnnotationsLayout hands it back with y intact. Block ids are the
server's: the editor gives one only to a block made here, never to a document it opens.
<TextEdit
format="markdown"
collaboration={{
document: ydoc,
provider,
user: { id, name, hue: "green" },
peer: (clientId) => members.byConnection(clientId),
}}
mentionLabel={(kind, id) => labels.get(`${kind}:${id}`)}
readOnly={!canEdit}
aria-label="Page"
/>Version diff
diff={{ doc, baseline }} shows the changes between two versions, formatted: added text and
blocks underlined on the success tint, removed ones struck through in the destructive ink (ins/del; a changed image is outlined), each
announced "Added:"/"Removed:" to a screen reader, under Compared with {baseline} and the two inks.
doc is the newer version's text with ychange marks on changed text and a ychange attribute
on whole blocks added or removed — the ProseMirror JSON a Yjs snapshot diff of two version states
produces (a server walker, checked with textEditDiffSchema() from text-edit-schema). The view
is read-only and ignores value; a new diff.doc replaces the shown one. The markings exist only
in a diff's schema (TextEditDiff), never in a document's.
<TextEdit
aria-label="Changes in this version"
diff={{ doc: diffJson, baseline: "the previous version" }}
/>Uploads
onImageUpload(file, { signal }) takes images pasted, dropped, or picked in the image panel's
Upload tab (the slash menu's Image). The image shows dimmed under a spinner until the promise
resolves with { src }, then lands in the document and commits — onValueChange and onCommit
both fire, even when focus has already left. The image lands with its size (,
read from the file unless the promise gives one), so every read view reserves its box. A rejection
calls onUploadError(file, error) and leaves the placeholder in place saying "Couldn't upload",
with Retry and Remove — a failure never vanishes without a word; onFailedUploadsChange(count)
reports how many wait, so a host guards leaving for them too. onFileUpload(file, { signal }) does the same for any other file
(dropped, pasted, or the Upload tab of the slash menu's File, Video or Audio) and inserts
[name](href) — or a video or audio block for a playable video or audio type; a link whose href starts
with fileLinkPrefix (default /api/files/) shows as a file chip. Without onFileUpload, other
files are ignored. While anything is still landing, handleRef.current.pendingUploads() counts it
and onPendingUploadsChange(count) reports each change — show "Uploading…" and guard leaving the
page until it is 0.
Images
An image works like Notion's. Hover it (or tap it on touch) for a small blurred toolbar at its
top-right: Replace (the image panel, whose upload or link takes its place — another site's link
imports through onExternalImage), Caption (Markdown's image title, , shown under the image — MarkdownView renders it as a figure with a figcaption), Alt text (edited in place), Open (the FileViewer,
paging through every image in the document; a double-click does the same) — 28px
BlockToolbar buttons, four at most — and a ⋯ menu with
Download (from imageDownloadHref(src) when given, a link that saves the file, else the image
itself) and Copy link. Delete is the block menu's, or Backspace on the selected image
(Cmd/Ctrl+Z undoes). An image that can't load keeps a dashed frame so it
can still be replaced. Selected, it shows a thin outline at the same rounded-lg radius, and pill handles on its
left and right edges resize it
with the aspect ratio kept — Tiptap's own ResizableNodeView, from 48px up to
the width left of the image — and while dragging, guides mark a quarter, a half,
three quarters and the full width, snapping within 8px. The width is part of the
document:  in Markdown (the Obsidian convention, which
MarkdownView reads and other renderers show as a plain image) and
width="320" in HTML. readOnly documents and posted comments open their
images in the viewer on a click and have no handles or Delete; wrap any other
MarkdownView in ImageViewerScope for the same.
A file chip (a link under fileLinkPrefix) opens in the same FileViewer on a double-click, or
from Open in its link panel; a file the viewer cannot render shows its name and a download.
Callouts and toggles
The slash menu's Callout wraps the block in GitHub's alert syntax — > [!NOTE], > [!TIP],
> [!IMPORTANT], > [!WARNING] or > [!CAUTION] — and renders it as an Alert (info, success,
default, warning and destructive), here and in MarkdownView; click the callout's icon to pick its
tone. Toggle inserts
<details><summary>Title</summary> around its blocks: open while editing, collapsed in
MarkdownView. Enter in a toggle's title moves into its body.
> [!NOTE] > The survey is booked for `09:30` on Monday. > [!TIP] > Use a 25 A breaker for the kitchen circuit. > [!IMPORTANT] > The client signs off each room before tiling. > [!WARNING] > Isolate the supply before opening the panel. > [!CAUTION] > Never work on a live circuit. <details><summary>Wiring colours</summary> - Brown: live - Blue: neutral </details>
Code, tables, toggles, video and audio
A code block shows its language picker top-left and two icons top-right: wrap lines and copy (a
check confirms the copy). Wrapping is saved with the block, after the language in the fence's info
string (```ts wrap; plain text that wraps is ```text wrap); unwrapped lines
scroll sideways inside the block only. Tables use a fixed layout, so typing in one column wraps in
its cell instead of pushing the others right; drag a column border to resize it. A toggle's
chevron opens and closes it (<details open> keeps it open for the reader); an empty title or body
shows a hint. Video and audio are blocks of their own — <video src="…"></video> and
<audio src="…"></audio> on a line — with the browser's native player, here and in
MarkdownView. The slash menu's Image, Video, Audio and File open an insert panel with Upload
(a dropzone, when the host takes that upload: onImageUpload for images, onFileUpload for the
rest) and Link (a URL and Insert) tabs. A dropped or pasted video or audio file lands as a
player.
```ts wrap const greeting = "A long line that wraps inside the block instead of scrolling sideways, because the wrap icon is on."; ``` ```bash pnpm dlx shadcn@latest add @vegastack/text-edit --overwrite --yes # long lines scroll inside the block ``` | Room | Notes | Owner | | --- | --- | --- | | Kitchen | Typing a long note here wraps inside the column. | Asha | <details open><summary>Site checklist</summary> - Isolate the supply - Photograph the panel </details> <video src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></video> <audio src="https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3"></audio>
Diagrams and other block views
blockViews draws the fences it names with a renderer instead of as code — in the read view and
the editor alike. With blockViews={{ mermaid: mermaidBlockView }} (from
Mermaid Block) a ```mermaid block is a Diagram: a
header with Preview / Code, Expand and a ⋯ menu (Copy source, Copy SVG, Download SVG).
Code shows the source — beside a live preview when the block is at least 720px wide — and the
diagram redraws 400ms after you stop typing; a source that stops parsing keeps the last diagram
that worked, dimmed, with the line and the problem under it. ⌘/Ctrl+Enter or Escape in the source
goes back to the preview and moves the caret on past the block; the caret arriving inside a
previewed block (arrow keys) opens its source; a double-click on the diagram does too. The mode is
the viewer's own, never saved in the document — the document keeps an ordinary fenced code block,
so exports, search and agents read the source. The slash menu and Turn into offer Diagram
(only while blockViews.mermaid is set): a new block opens in Code with the view's starter — for
mermaidBlockView, one %% comment saying what to type, which draws "Nothing to draw yet" until a
diagram is typed. Mod Enter, or ↓ on the source's last line, leaves
the diagram for the line below (a new one when none follows), as in a code block.
import { TextEdit } from "@/components/ui/text-edit";
import { mermaidBlockView } from "@/components/ui/mermaid-block";
<TextEdit
format="markdown"
value={page.body}
onCommit={save}
blockViews={{ mermaid: mermaidBlockView }}
/>;Embeds and external images
A YouTube or Loom link alone on its line is an embed block — an atom { provider, url }
(provider is "youtube" or "loom") with a stable blockId, selectable and draggable like an
image. Its Markdown is the URL as written, alone on its line, so every other reader shows a link;
the parse makes a paragraph that holds only such a link (a bare URL, <url>, or [url](url) with
no title) an embed again — in the document, a quote, a callout or a toggle. A lone link to any other
host, a link with other text, and one in a list item or a table cell stay links. In HTML it is a
link inside div[data-type="embed"].
Players are opt-in: pass embeds={EMBED_PROVIDERS} (from @/lib/embed-providers, or a subset
such as { youtube: EMBED_PROVIDERS.youtube }) once the page's Content-Security-Policy allows
EMBED_FRAME_ORIGINS in frame-src. Without it an embed block shows as a link to its URL, a
pasted video link stays a link, and the video panel's Link tab takes only video files — the
document and its Markdown are the same either way.
With embeds, the block draws a facade: a 16:9 card with the provider's glyph, "YouTube video" or "Loom video",
and Play (aria-label="Play YouTube video"). Nothing is requested from the provider until
Play; then a lazy, sandboxed player frame takes the card's place — its src is rebuilt from the
video id (https://www.youtube-nocookie.com/embed/<id>, a YouTube t= carried as ?start=;
https://www.loom.com/embed/<id>), never taken from the URL, and it carries its own
referrerpolicy="strict-origin-when-cross-origin", so it plays on a no-referrer page. Printed,
it is the URL. Allow EMBED_FRAME_ORIGINS (from @/lib/embed-providers) in your
Content-Security-Policy's frame-src.
- Paste a lone YouTube or Loom link into an empty paragraph and it embeds; pasted beside text it stays a link. Either way the paste popover offers Embed · Link (Embed beside text puts the player after the line), and Cmd/Ctrl+Z right after leaves the URL as text. With Cmd/Ctrl+Shift+V it pastes as typed.
- Video in the slash menu (it also answers to "youtube", "loom" and "embed") opens the video panel: its Link tab embeds a YouTube or Loom link, plays a video file's link natively, and says "Paste a YouTube or Loom link" for anything else.
onExternalImage(url, { signal }) imports images pasted or dropped from another site. Each image
whose src is not this origin's (and not a blob:) is taken out of the paste before it reaches
the document, shows an upload placeholder, and lands as { src, alt } from your promise — or, when
it rejects, as a link to the original, with the toast "Couldn't import that image. A link was added
instead." A pasted data: image (Word, Google Docs) uploads as a file through onImageUpload. One
paste imports up to 20 images, three at a time; the rest land as links. Without onExternalImage,
pasted images are kept as they are.
The walkthrough, and Monday's recording: https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=30s https://www.loom.com/share/0281766fa2d04bb788eaf19e65135184 Paste a YouTube or Loom link into an empty line, or type / and pick Video.
<TextEdit
format="markdown"
value={body}
onValueChange={setBody}
embeds={EMBED_PROVIDERS}
onImageUpload={uploadImage}
// Your server fetches the image (SSRF-safe), stores it, and returns its URL.
onExternalImage={(url, { signal }) => rehostImage(url, { signal })}
/>Live task lists
A live task list is a taskQuery block, an atom { blockId, kind, query, snapshot } whose
kind is null (a task list stores no kind, so its shared state is unchanged). It is selectable
and draggable like an image, and nothing inside it is edited. Its Markdown is a container
directive:
:::tasks{id="lb_9f2c" v="1" space="page" assignee="me" status="open" sort="due" limit="25"}
- [ ] [@Order glass for block B](mention://task/0192…) — To do
:::blockId is the directive's id. It is the one block id Markdown carries, because the host keys
the list's records by it. query holds every other attribute as written, unknown keys included.
snapshot holds the rows last stored. The block round-trips byte for byte. Lines that do not make
a whole block stay text and a list, and a paragraph that starts with :::<name>{ is written
\:::<name>{, so it never turns into one. MarkdownView § Live task lists has the full grammar.
The same node holds any other live block the app reads: with a kind
{ name: "table", rowKind: "row", label: "Table view" } added to liveBlockKinds (after
DEFAULT_LIVE_BLOCK_KINDS), the editor reads :::table{…} … ::: as a taskQuery node with
kind: "table" (the node keeps its stored name whatever its kind, since
every shared document's state holds it). A name not in the list stays text. Where the host draws
nothing, the node shows its snapshot under its kind's label (MarkdownLiveBlock). renderLiveBlock(kind, props) draws every kind, tasks included, with the same props
as renderTaskQuery; without it renderTaskQuery draws tasks alone.
renderTaskQuery({ blockId, query, snapshot, editable }) draws the list with your own component,
which fetches this reader's rows. It runs in the editor (editable follows the editor) and in the
read view before it (editable: false). In an editable editor it also gets setQuery(query),
which replaces the list's filter as one local edit (undoable, carried to collaborators); read views
get none. Without it, or where it returns null, the snapshot shows
as MarkdownLiveBlock's quiet card. Insert a list from your own slash item with
{ type: "taskQuery", attrs: { query, snapshot } } (add kind for another block). A
collaborative editor gives it a blockId.
<TextEdit
format="markdown"
value={body}
onValueChange={setBody}
renderTaskQuery={({ blockId, query, setQuery }) => (
<LiveTaskList
pageId={pageId}
blockId={blockId}
query={query}
onFilterChange={setQuery}
/>
)}
/>Outline
onOutlineChange receives the headings (#–####) with stable ids — the heading's words,
hyphenated, -1, -2… for a repeat, plus the heading's blockId once the editor is in — after load
and when idle after a change (300ms); the same ids are on
the heading elements (and on MarkdownView's with headingIds). It does not load the editor: the
read view reports the outline from the Markdown until the editor is in. handleRef gives an imperative
handle: scrollToHeading(id) (an outline id, a block id or <slug>~<blockId>; returns whether it
found the heading), getStats(), openFind(), flush() (commit now, and wait for uploads in flight — before
creating a comment or navigating), focus(), getAnchorForSelection(), pulseAnnotation(id) and
markSaved() (the host saved the current document: it becomes the baseline Escape reverts to).
Comment highlights
annotations draws comment highlights — { id, anchor }, where an anchor is offsets into the
document's text plus the quoted words and up to 80 characters of context (TextAnchor). They are
decorations, never written into the Markdown. Each anchor resolves on load (the stored offsets if
the words there still match, else the one place its context occurs, else the one place its quote
occurs, else orphaned), then rides along with edits: typing before a highlight keeps it on the same
words. onCreateAnnotation(anchor) adds Comment to the selection bubble — read-only too, never
in a code block. onAnnotationsLayout reports each highlight's top (px from the root; null when
orphaned) and its current anchor, for comments beside the text and for persisting moved anchors.
activeAnnotationId fills one highlight; onAnnotationClick and onAnnotationHover (after 250ms)
report the pointer. Setting annotations (or onOutlineChange) mounts the editor at once, and
readOnly then keeps it not editable with the highlights drawn.
The anchor helpers are server-safe libs: anchorFromRange(text, start, end) and
resolveAnchor(text, anchor) in @/lib/text-anchor; anchorText(doc), offsetsToRange and
rangeToOffsets (ProseMirror documents) in @/lib/text-anchor-doc.
Floating parts and a sticky header
Every floating part — the selection bubble, the link panel, the slash and mention menus — keeps 8px
from the viewport's edges and flips to stay in view. floatingSafeArea adds room at the top and
bottom for an app's own fixed chrome: pass a sticky header's height ({ top: 56 }) or a bottom
bar's ({ bottom: 64 }) and no part ever opens under it — the bubble flips below a selection near
the header, the slash menu above a caret near the bar — and a selection under the header counts as
scrolled out, so its bubble hides. On a coarse pointer the selection bubble opens below the
selection, where the platform's own selection callout is not.
<TextEdit
format="markdown"
typeScale="document"
defaultValue={page.markdown}
onCommit={save}
floatingSafeArea={{ top: 56 }}
/>Count pills and the keyboard
annotationCounts puts a count pill after each drawn highlight — the thread's count (absent
counts as 1) — so the text shows where comments are without hover or a margin. "auto" shows it
only on a coarse pointer or below the lg breakpoint, in CSS; "always" everywhere; "never" (the
default) renders none. The pill is a widget, contenteditable="false": it is never in the document,
its Markdown or a copy, clicking it keeps the caret where it was, and it follows the active thread
(data-active). It has a 24px hit area and is named by annotationCountLabel (“3 comments”), the
visible number aria-hidden. Every highlight opens from the keyboard, through onAnnotationClick:
read-only, each is a tab stop named by annotationLabel (“3 comments on “25 A breaker””) that Enter
or Space opens; while editing, a focusable highlight would take the caret and Tab from the text, so
instead Alt+Enter with the caret in a highlight opens it. The pill is never a tab stop — the
highlight or the shortcut already is.
Read-only — Tab to a highlight, Enter opens it
Editing — caret in a highlight, Alt+Enter opens it
Invalid
Pass aria-invalid to mark the editor invalid: it forwards to the contenteditable textbox and the
root carries data-invalid. A document editor draws no border, so the error text is the visible
cue; a boxed one also turns its border destructive (see Boxed, invalid).
Connect the error text with aria-describedby so it is announced with the region. aria-invalid
also accepts "grammar" and "spelling".
A comment is required before you can post.
Inside a Field
Inside a Field the contenteditable takes its label, the rendered
description and error ids, and aria-invalid from the Field, so a FieldLabel names the editor
without an id or aria-labelledby. An explicit aria-labelledby still wins.
Shown at the top of the meeting page.
Submit on Cmd/Ctrl+Enter
Set onSubmit to get a keyboard submit affordance — pressing Cmd/Ctrl+Enter
fires it with the current value. It's presentational only: the host decides what submitting means
(save, send, …), and plain Enter still inserts a newline. Omit onSubmit to disable it.
Sized content area
minHeight sets a starting height and maxHeight caps it — content past the cap scrolls
(overflow-y-auto). A number is treated as px; a string (e.g.
'8rem') is used verbatim. Both are fed to the --te-min-h / --te-max-h CSS variables.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
actions | React.ReactNode | — | variant="composer": the trailing controls (Send), bottom-right. |
activeAnnotationId | string | null | null | The annotation shown as active: its highlight fills. |
annotationCountLabel | ((count: number) => string) | (count) => count === 1 ? "1 comment" : `${count} comments` | The count pill's accessible name (the visible number is aria-hidden). |
annotationCounts | "always" | "auto" | "never" | "never" | A count pill after each highlight (not orphaned ones), showing its count — where the text
shows comments without hover or a margin. auto shows it only on a coarse pointer or below
the lg breakpoint (CSS only); always everywhere; never renders none. The pill is a
widget (contenteditable=false, never in the document or its Markdown, never copied) with a
24px hit area, a button that calls onAnnotationClick without moving the caret, and follows
the active highlight (data-active). It is not a tab stop: the highlight (view mode) or
Alt+Enter (editing) is the keyboard path. |
annotationLabel | ((quote: string, count: number) => string) | (quote, count) => count > 1 ? `${count} comments on “${quote}”` : `Comment on “${quote}”` | A view-mode highlight's accessible name, from its quoted text and count. |
annotations | readonly TextEditAnnotation[] | — | Comment highlights, drawn as decorations over their anchored text (never written into the document), and kept on the same words while the text around them changes. Setting it mounts the editor at once — read-only too, where the highlights still draw. |
aria-describedby | string | — | Id(s) of helper or error text describing the editor. |
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the textbox invalid. |
aria-label | string | — | Accessible label for the editable region. |
aria-labelledby | string | — | Id(s) of the element(s) that label the editable region. |
autosave | number | boolean | false | Also call onCommit after this many milliseconds without typing (true is 1000ms). |
blockViews | Readonly<Record<string, BlockView>> | — | Fenced languages drawn by a block view instead of as code — { mermaid: mermaidBlockView }
draws ``` mermaid blocks as diagrams, in the read view and the editor alike. Each block
shows a header with the view's label, Preview / Code (Code splits beside a live preview on a
block at least 720px wide when the view sets split), Expand and a ⋯ menu. The mode is this
viewer's own, never saved. ⌘/Ctrl+Enter in the source goes back to the preview, as does
Escape; moving the caret into a previewed block opens its source. With mermaid set, the
slash menu and Turn into offer **Diagram**, which inserts the view's starter`.
@default undefined |
bookmarks | boolean | false | Bookmarks: a link shown as a card with its page's title, icon and site — **Bookmark** in the
slash menu (paste or type the address into the new card), and **Link · Bookmark** in the
popover under a lone link pasted where a block can stand (**Embed · Link · Bookmark** for a
video with embeds). The preview comes from the InlineChipProvider's linkPreview (the
app's server reads the page; the reader's browser never contacts the site). Stored as
[url](url "bookmark") on its own line — a link to any other reader. Without it a bookmark
already in the document still shows as a card. |
bubbleActions | ((context: TextEditBubbleContext) => React.ReactNode) | — | The host's own selection actions ("Ask agent…"), after the formatting and before Comment, in the
bubble (and the dock). Called with the selection's text each time the bubble draws; return
Buttons (variant="ghost" size="sm"). With onMakeTask, a selection reaching unlinked
checklist lines also shows **Make task** / **Make N tasks** there. |
children | React.ReactNode | — | Rendered inside the box, after the document — a composer's actions row. Meant for
variant="boxed". |
citation | ((n: number) => MarkdownCitation | null | undefined) | — | MarkdownView's citation, for the read view: a [[n]] marker renders as a superscript
citation, and clicking it is the citation's, never an edit. In the editor the markers stay
plain [[n]] text, so with citation set the read view stands in whenever the document is not
being edited: the editor swaps in on a click or focus and the read view returns once focus
leaves the editor and its menus and panels (after the blur commit, never mid-upload). |
className | string | — | Additional class names on the editor container. |
collaboration | TextEditCollaboration | — | Edit a shared Yjs document live with others. The document is the truth: value,
defaultValue, onValueChange, onCommit, onRevert, autosave and markSaved are
ignored, Escape blurs, and undo (⌘Z) undoes only this viewer's own changes. Each
collaborator's caret and selection show in their hue. The editor mounts at once — read-only
too (readOnly), so a viewer sees text and carets move. A toggle's open state is each
viewer's own, and a record mention is stored without its title (mentionLabel). |
defaultValue | string | '' | Uncontrolled initial content, used only on first render. Ignored when value is provided. |
diff | TextEditDiffView | — | Show a version diff, read-only: added text and blocks underlined in the success ink, removed ones struck
through in the destructive ink (ins/del, each announced "Added"/"Removed"), with a legend
naming its baseline. The document is diff.doc — value, defaultValue and every editing
prop are ignored. Fixed at mount: a TextEdit is a diff view or an editor, never both; a new
diff.doc replaces the shown one. |
disabled | boolean | false | Disable the editor: the surface reports aria-disabled, and the root dims. |
dragHandles | boolean | true | Show the hover chrome: the ⋮⋮ drag handle beside the hovered block, and a table's row and column grips (menus, drag to reorder), corner grip and "+" bars. Turn it off where the editor sits in a tight box (comments). ⌘⇧↑ / ⌘⇧↓ move blocks and Tab / ⇧Tab move between cells either way. |
embeds | Partial<Readonly<Record<EmbedProviderId, EmbedProvider>>> | — | The video providers shown as players: EMBED_PROVIDERS from @/lib/embed-providers, or a
subset. Opt-in — without it an embed block shows as a link to its URL, a pasted video link
stays a link, and the video panel takes only video files; allow EMBED_FRAME_ORIGINS in the
page's frame-src before passing it. |
emoji | boolean | false | Type <kbd>:</kbd> and two or more letters for an emoji menu (from emoji-data, loaded on
first use); the pick lands as the character itself. 10:30, Note: and https:// never open it. |
escapeBehavior | "blur" | "revert" | variant === "document" || autosave ? "blur" : "revert" | What Escape does. "revert" restores the document as focus found it (or as the last
markSaved() left it), blurs and calls onRevert — right for a field edited then confirmed.
"blur" keeps the text, commits it and blurs — right for a document, where reverting would
erase text already saved. Defaults to "blur" for a document (and whenever autosave is
set); a collaboration editor always blurs. |
fileContentType | ((href: string) => string | null | undefined) | — | MarkdownView's fileContentType, for the read view shown before the editor activates. |
fileLinkPrefix | string | "/api/files/" | A link whose href starts with this renders as a file chip, here and in MarkdownView. |
findReplace | boolean | false | Find and replace: <kbd>⌘F</kbd> inside the editor opens a find bar that floats over the top of
the text, at its end, like a browser's — the document never moves (Replace only while editable), <kbd>⌘G</kbd> / <kbd>⇧⌘G</kbd> step through the matches,
and <kbd>Esc</kbd> closes it with the caret back where it was. Outside the editor the
browser's own find runs. handleRef.openFind() opens it from a menu. |
floatingSafeArea | { top?: number; bottom?: number; } | — | Px every floating part — the selection bubble, the link panel, the slash and mention menus —
keeps clear at the viewport's top and bottom, beyond its 8px edge gap: a sticky app header's
height ({ top: 56 }) or a bottom bar's, so none ever sits under one. The selection bubble
opens below the selection on a coarse pointer (the platform's own callout sits above). |
footer | React.ReactNode | — | variant="composer": content inside the box under the text (the draft's file cards). |
format | "html" | "markdown" | "html" | What value, defaultValue and onValueChange carry: HTML, or Markdown (CommonMark + GFM
through @tiptap/markdown, lossless for headings, lists, tasks, code, links, images, quotes
and tables). Fixed for the editor's life. |
handleRef | React.Ref<TextEditHandle> | — | The imperative handle: scrollToHeading, getStats, openFind, flush, focus,
getAnchorForSelection, pulseAnnotation. |
headingLinkHref | ((anchor: TextEditHeadingAnchor) => string | null) | — | A heading's link, copied by the # button that shows on a hovered heading or the caret's, and
by Copy link in a heading's block menu ("Link copied"). Resolved against the page's address;
null shows no link for that heading. MarkdownView's prop of the same name. |
id | string | — | id applied to the contenteditable surface. |
imageDownloadHref | ((src: string) => string | null | undefined) | — | The link an image's Download (its toolbar and the viewer) saves from, given the image's src
— a link that answers as an attachment, say. Without it, or when it returns nothing, Download
uses the src itself. |
inlineImageTypes | readonly string[] | ["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"] | The image types a paste, drop or pick inserts inline (through onImageUpload); any other
file — an SVG, a HEIC photo — goes to onFileUpload and lands as a file chip. |
inset | "auto" | "none" | "auto" | The text's inline start. auto: inside a Dialog, Sheet, Drawer, Popover or Card whose padding
is narrower than the block grip needs, the content takes just enough start padding for the ⋮⋮
grip to sit before the text. none: the text starts at the editor's own edge — flush with a
ghost Input or Textarea title above it (a New task dialog's description), placeholder and
caret included — and the block grip, which would have no room, is not shown. Everything it
opens stays: ⌘/ and ⇧F10 open the block menu, and on touch a long-press does (as with
touchBlockMenu="longPress", which inset="none" implies),
⌘⇧↑ / ⌘⇧↓ move blocks, and a table keeps its own grips. |
leading | React.ReactNode | — | variant="composer": the leading controls (attach), bottom-left. |
liveBlockKinds | readonly LiveBlockKind[] | DEFAULT_LIVE_BLOCK_KINDS — the live task list (`tasks`) alone | The live block kinds Markdown reads (:::<name>{…} … :::), read when the editor is
created, each with the heading and row kind its stored snapshot shows with where the host
draws nothing (MarkdownLiveBlock); a block of any other name stays text and a list. A shared
document's blocks come from its state, whatever their kind. |
maxHeight | string | number | — | Maximum height of the editable content area; it grows with its text up to this, then scrolls inside. |
mentionHref | ((kind: MentionKind, id: string) => string | null) | — | Where a mention chip links, by kind and id; ⌘/Ctrl-click opens it while editing. People are
never links, and neither is a restricted: id (a target the reader may not open). |
mentionImage | ((id: string) => string | null | undefined) | — | A person mention's avatar URL, by id: its chip shows it (16px) in place of the person icon. |
mentionLabel | ((kind: MentionKind, id: string) => string | null | undefined) | — | A record mention's label for this reader, by kind and id. A collaborative document stores a
page's, task's or file's mention without its title (one reader may see a title another may
not), so each chip asks here; people and agents keep their stored name. A chip it cannot
label reads @…. Chips re-render when this function changes. |
mentions | TextEditMentions | — | @ mentions (Markdown format): typing @ after a space or at a line start opens a menu of
people, pages, files and tasks from search. A pick becomes a chip stored as
[@<label>](mention://<kind>/<id>). |
mentionStatus | ((kind: MentionKind, id: string) => TextEditMentionStatus | null | undefined) | — | A target's state, by kind and id: { done: true } gives a finished task's chip its check (a
task chip shows the task glyph otherwise); { struck: true, status: "Canceled" } strikes the
chip's label through and adds a small status tag. Read at render, like mentionLabel; chips
re-render when this function changes. null leaves the chip to the InlineChipProvider's
state; {} is a plain chip. Any state given here replaces the provider's whole answer. |
menuPresentation | "auto" | "popover" | "popover" | Where the slash and mention menus show (SuggestionMenu's presentation): auto opens a
bottom sheet with a search field on a coarse pointer or a window under 640px, and floats at
the caret otherwise. |
menuSize | "default" | "lg" | "default" | The slash and mention menus' size (SuggestionMenu's): lg is 320px wide and up to
min(60vh, 440px) tall, each block with a line saying what it is, the slash menu grouped Basic ·
the host's items · Media · Advanced. |
minHeight | string | number | — | Minimum height of the editable content area (a number is px). |
onAnnotationClick | ((id: string) => void) | — | Called with an annotation's id to open its thread: a click on its highlight or its count pill,
and from the keyboard — in view mode (readOnly) each highlight is a tab stop (a button named
by annotationLabel) that opens on Enter or Space; while editing, a highlight is never a tab
stop (it would steal the caret and Tab), so Alt+Enter with the caret inside or at either end
of a highlight opens it. Without it, highlights are neither tab stops nor buttons. |
onAnnotationHover | ((id: string | null) => void) | — | Called with an annotation's id after the pointer rests on its highlight for 250ms, and with
null when it leaves. |
onAnnotationsLayout | ((items: TextEditAnnotationLayout[]) => void) | — | Each annotation's highlight top (px from this root's top; null when orphaned) and its current anchor — for laying comments beside the text and persisting moved anchors. Called once per frame when anything changed. |
onCommit | ((value: string) => void) | — | Called with the serialized document when an edit is committed: focus leaves the editor with a
document that differs from the one focus arrived with, the page is hidden, the editor
unmounts mid-edit, or autosave fires. One commit path — a value is never committed twice. |
onCreateAnnotation | ((anchor: TextEditAnchor) => void) | — | Adds "Comment" to the selection bubble (read-only too; never in code blocks) and calls this with the selection's anchor. |
onExternalImage | ((url: string, options: { signal: AbortSignal; }) => Promise<{ src: string; alt?: string; }>) | — | Import an image pasted or dropped from another site: resolve with where your storage keeps a
copy (src). Each such image (one whose src is not this origin's or a blob:) is taken out
of the paste, shows an upload placeholder in its place, and lands as the stored image — or, when
the promise rejects, as a link to the original with the toast "Couldn't import that image. A
link was added instead." A pasted data: image uploads as a file through onImageUpload.
Up to 20 images a paste (the rest land as links), 3 at a time. Without it, pasted images are
kept as they are. |
onFailedUploadsChange | ((count: number) => void) | — | Called with the number of failed uploads still shown (waiting for Retry or Remove), each time it changes — so a host can guard leaving, as it does while uploads land. |
onFileUpload | ((file: File, options: TextEditUploadOptions) => Promise<TextEditFileUpload>) | — | Upload any other file dropped, pasted or picked (the slash menu's File): it lands as a link
[name](href), shown as a file chip. Without it, non-image files are ignored. While it uploads
its chip shows a ring that fills as onProgress reports (a spinner until it does). |
onImageUpload | ((file: File, options: TextEditUploadOptions) => Promise<TextEditImageUpload>) | — | Upload an image pasted, dropped or picked (the image panel's Upload, the slash menu's Image).
It shows dimmed under a spinner until the promise resolves with its URL, then commits — even
after focus has left. A rejection leaves it in place as a failed upload (Retry, Remove) and
calls onUploadError. signal aborts when its placeholder is deleted or the editor unmounts;
signal.reason says which (TEXT_EDIT_UPLOAD_ABORT). Call onProgress(0…1) as the upload
goes and the placeholder (the image itself, dimmed, in its own proportions) shows a bar along
its foot instead of a spinner. |
onMakeTask | ((items: TextEditTaskItem[], context: TextEditMakeTaskContext) => void) | — | Adds **Make task** to the block menu of an unlinked checklist line (**Make N tasks** when
several are selected), and a **→ Task** button at the end of an unlinked checklist line the
pointer rests on; called with the lines and the first line's element (anchor — point a
MakeTaskPopover at it). Create the tasks, then linkTaskItems. |
onOutlineChange | ((headings: TextEditOutlineItem[]) => void) | — | The document's headings (#–####) with stable ids, after load and 150ms after each change —
an outline rail. The read view reports them from the Markdown before the editor is in, so
setting it never loads the editor on its own. |
onPendingUploadsChange | ((count: number) => void) | — | Called with the number of uploads still landing, each time it changes — so a host can show "Uploading…" and guard leaving (a document saved while an image uploads is not saved yet). |
onRevert | (() => void) | — | Called after Escape reverts the document to what it was when focus arrived and blurs the
editor (escapeBehavior="revert"). Nothing is committed. |
onSectionMark | ((key: string) => void) | — | A section mark's marker was clicked: its key. |
onStatsChange | ((stats: TextEditStats) => void) | — | The document's word and character counts, when idle after a change (300ms) and once after load — a word-count popover, a size warning. Counting never runs per keystroke. |
onSubmit | ((value: string) => void) | — | Fired on Cmd/Ctrl+Enter with the serialized document (send a comment, create the record). Without it, Cmd/Ctrl+Enter commits and blurs. |
onTaskItemToggle | ((taskId: string, checked: boolean) => void) | — | A linked checklist line (linkedTaskId) was ticked or unticked by this person — never by a
collaborator, a value change or setTaskItemChecked. The host sets the task's status. |
onTaskOpen | ((taskId: string) => void) | — | A linked checklist line (linkedTaskId) draws as a TaskInline: one round box (the task's
done state), the title — which calls this on a click, or <kbd>Alt</kbd>+<kbd>Enter</kbd> with
the caret on the line: open the task's peek — then the assignee's avatar and the due date
(mentionStatus's assignee, due and overdue, else the InlineChipProvider's state)
and ↗ to the task's page (mentionHref) on hover. Without it the line is a checkbox and a task
chip, as before. |
onUploadError | ((file: File, error: unknown) => void) | — | Called when an upload rejects — show a toast. Its placeholder stays in the text, saying the
upload failed, with Retry and Remove (onFailedUploadsChange counts them). |
onValueChange | ((value: string) => void) | — | Called with the serialized document whenever it changes. |
placeholder | string | — | Shown, faint, in the first line while the document is empty — "Add a description…", "Add a summary…", "Add a comment…". While focused and still empty it becomes the "Type / for commands" hint (when any slash command is allowed). |
readOnly | boolean | false | Render the document without letting it be edited; the surface reports aria-readonly. Its
images still open in the FileViewer on a click. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the editor's root container <div>. |
renderLiveBlock | LiveBlockRender | — | Draws a live block of any kind — a taskQuery node with that kind attribute, :::<kind>{…}
in Markdown (a name in liveBlockKinds) — with the host's own component, in the editor and in
the read view before it. Called with the block's kind and the same props as
renderTaskQuery. Given, it is asked for every kind, tasks included, and renderTaskQuery
is not used. Returning null/undefined leaves the snapshot (MarkdownLiveBlock). Insert one
with { type: "taskQuery", attrs: { kind, query, snapshot } } (kind: null is a task list). |
renderTaskQuery | ((props: TaskQueryRenderProps) => React.ReactNode) | — | Draws a live task list — a taskQuery block, :::tasks{…} in Markdown — with the host's own
component (it fetches this reader's rows), in the editor and in the read view before it.
blockId is the list's id (the directive's id), query its filter (every other attribute,
as written), snapshot the rows last stored, editable whether this reader may change the
filter. The block is an atom: selectable and draggable, nothing inside is edited by the editor.
Without it, or where it returns null/undefined, the snapshot shows as a quiet read-only list
(MarkdownLiveBlock). Insert one with { type: "taskQuery", attrs: { query, snapshot } }; a
collaborative editor gives it a blockId. |
saving | boolean | false | Mark the editor busy (aria-busy) while the host persists a commit. |
sectionMarks | readonly MarkdownSectionMark[] | — | Marked sections: each draws a small marker in the gutter beside its heading (named label)
and a tone bar along the section — decorations only, with no layout shift. A key matching
no heading draws nothing. |
slashActions | readonly TextEditSlashAction[] | [] | The host's own / menu items, listed after the built-in ones and filtered by the same query
(label and keywords). Choosing one removes the typed /query and calls onSelect, which can
insert a mention chip at the caret through ctx.insertMention (a new sub-page, say). A
disabled item shows muted with its disabledReason and cannot be chosen. |
slashCommands | readonly TextEditSlashCommand[] | TEXT_EDIT_SLASH_COMMANDS (all) | The blocks the / menu offers, in order. [] turns the menu off. Markdown typed or pasted
for any block still works — this limits the menu, not the schema. |
slashHint | boolean | true | Whether the placeholder yields to the "Type / for commands" hint while focused and empty. Off keeps the placeholder (a comment box's "Add a comment…") in every state. |
toggleState | TextEditToggleState | — | In a shared document, each viewer's own open and closed toggles, kept by the host (in
localStorage, say) so a reload keeps them. A find result, a heading link or a comment inside a
closed toggle opens it for the moment without recording it here. |
toolbar | "bubble" | "dock" | "bubble" | Where the selection's formatting shows on a coarse pointer: bubble floats it over the
selection; dock puts it in a 44px bar at the bottom of the window (above the on-screen
keyboard), as phone editors do. A fine pointer always gets the bubble. |
touchBlockMenu | "handle" | "longPress" | "handle" | The block menu on a coarse pointer: handle shows the ⋮⋮ grip beside the caret's block (a tap
opens the menu); longPress shows no grip — a long press on a block (half a second, held
still) opens its menu as a bottom sheet. |
typeScale | "compact" | "document" | "compact" | The type scale, as MarkdownView's typeScale: compact (the app's own) or document (a
page's, TYP-20: 14/24 text, 16/26 on a coarse pointer or a narrow window, # 24 · ## 20 ·
### 16, 12px between paragraphs). The read view and the editor wear the same metrics. |
value | string | — | Controlled value. Synced into the editor when it changes externally and the editor is not focused — a change that arrives while focused is applied on blur, so the caret never jumps. |
variant | "boxed" | "composer" | "document" | "document" | The editor's chrome and focus cue. document is the Notion-style page editor (a task
description, a meeting summary): no border and no fill in any state, the caret is the focus
cue. boxed frames the editor in a bordered field (a comment composer): the border darkens
subtly with a 150ms ease while the editor holds focus, an invalid box keeps its destructive
border, and a click anywhere in the box starts editing. Neither mode paints a focus fill.
composer is a comment box that looks exactly like Input at rest (32px, its border, radius,
padding, type and focus cue) and grows line by line to about ten lines, then scrolls inside;
actions sit at its end on the first line and stay pinned bottom-right as the text wraps, and
footer (pending file cards) renders inside the box under the text. |
Data attributes and CSS variables on TextEdit
| Attribute | Values |
|---|---|
data-disabled | "" |
data-editable | "" |
data-invalid | "" |
data-placeholder | mirrors a prop or state value |
data-slot | "text-edit" | "text-edit-actions" | "text-edit-content" | "text-edit-footer" | "text-edit-leading" |
data-variant | mirrors a prop or state value |
ImageViewerScope takes a div's props plus:
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Leave the images as plain pictures. |
imageDownloadHref | ((src: string) => string | null | undefined) | — | The link the viewer's Download saves from, given an image's src (TextEdit's
imageDownloadHref). Without it, Download uses the src. |
Data attributes and CSS variables on ImageViewerScope
| Attribute | Values |
|---|---|
data-slot | "image-viewer-scope" |
Accessibility
- The editable surface is a
role="textbox"witharia-multiline="true". Give it anaria-label(or associate a visible label) so the region is announced. - Use
aria-invalidandaria-describedbyto connect validation state and helper/error text to the contenteditable textbox. - The slash menu is a
listboxofoptions witharia-selected; focus stays in the textbox, which points at it witharia-controlsand at the active option witharia-activedescendant, and ↑ ↓, Enter and Esc drive it. Moving the pointer over a row makes it the active one too. With no matches the menu is astatus(“No results”), not an empty listbox. - The bubble menu is a Base UI
Toolbarlabelled “Selection formatting”: one tab stop, arrow keys between itsToggles, each with anaria-labelandaria-pressed. WithonCreateAnnotationit leads with a Comment button; read-only, the toolbar (“Selection actions”) holds only that. - The mention menu is a
listboxlabelled “Mention” whosegroups are named by their headings (People, Pages, Files, Tasks); focus stays in the textbox, as with the slash menu, with the samearia-activedescendantwiring. Its placeholder rows are hidden from assistive technology; when a search fails or finds nothing, the menu is astatus(“Couldn't search”, “No matches”). A chip reads as its name. - The paste popover is a
toolbarlabelled “Paste as” whose buttons say which choice is in effect (aria-pressed); what a lone link became is announced (“Pasted as a mention”, “Embedded”, “Pasted as a link”, each with “Tab to change”). Focus stays in the text until Tab moves onto the choice in effect; the arrows move between the choices, Enter picks one and Escape returns to the text. - An upload placeholder is a
role="status"named “Uploading” and the file’s name; a failed one is agroupnamed “Couldn't upload” and the file’s name, its Retry and Remove buttons named for the file. - A callout is a
role="note"named by its tone; while editable its icon is a menu button, “Callout type: Note”, that picks the tone. - Comment highlights are decorations with a dashed underline. With
onAnnotationClick, a read-only highlight is arole="button"tab stop named byannotationLabel(“Comment on “…””): one per highlight, on its first text span, the rest of its text hidden from assistive tech because the name already quotes it; its focus is base.css'saccenttint. While editing a highlight carries no role and is no tab stop, so the caret, Tab, typing and IME are untouched — Alt+Enter opens it instead. A count pill (annotationCounts) is arole="button"named byannotationCountLabel, out of the tab order. - Editing keymaps come from Tiptap
StarterKitplus a few of ours — see the table below. Indocumentmode focus is shown by the caret itself: the surface has no ring, border or tint (it declaresdata-focus-cue="caret"). Inboxedmode the box's border darkens subtly while the editor holds focus and nothing fills (data-focus-cue="border"); an invalid box keeps its destructive border. Either way forced-colours mode restores a system outline. - The table grips are buttons labelled "Row options", "Column options" and "Table options"; each
opens a Base UI
DropdownMenu(arrow keys, Enter, Esc, focus returns to the editor). From the keyboard, Shift F10 (or the context-menu key) in a table opens the menu for the selection: a selected column (Shift + arrows across cells), the whole table, else the caret's row. The "+" bars are labelled "Add row" and "Add column". The block handle is pointer-only and hidden from assistive tech, because Mod Shift ↑ / ↓ does the same from the keyboard. - An invalid control (
aria-invalid) setsdata-invalidon the root for downstream styling; pair it with visible error text througharia-describedby. readOnlyrenders a document, not a field: the surface isrole="document"with noaria-multilineoraria-readonly, so nothing announces a text box that cannot be typed in.- In
collaborationmode a collaborator's caret and name are presence cues, hidden from assistive tech (aria-hidden); a toggle's chevron keepsaria-expandedfor this viewer's own state.
| Key | Action |
|---|---|
| Cmd/Ctrl + B | Toggle bold. |
| Cmd/Ctrl + I | Toggle italic. |
| Cmd/Ctrl + E | Toggle inline code. |
| Cmd/Ctrl + Shift + X | Toggle strikethrough. |
| Cmd/Ctrl + Shift + 7 | Numbered list (8 bullet, 9 checklist). |
| Cmd/Ctrl + Shift + ↑ | Move the block up (↓ down). |
| Cmd/Ctrl + Z | Undo. |
| Cmd/Ctrl + Shift + Z | Redo. |
| Enter | New paragraph (or new list item inside a list). |
| Cmd/Ctrl + Enter | Fire onSubmit with the current value (when set); otherwise commit and blur. Plain Enter still inserts a newline. |
| Esc (in a diagram's source) | Back to the diagram's preview (blockViews), as Cmd/Ctrl+Enter is there; the caret moves on past the block. |
| Esc | With "blur" (the default for a document, with autosave and always in collaboration): commit and blur. With escapeBehavior="revert" (the boxed and composer default): revert to the value focus found, blur, fire onRevert. |
| Cmd/Ctrl + K | Open the inline link input (add, edit or remove the link); the key stops here, so a page's own ⌘K palette does not open too. |
| @ | Open the mention menu (with mentions). |
| Tab | Indent a list item, or move to the next table cell (the last cell adds a row); elsewhere, move focus out of the editor. |
| Shift + Tab | Outdent a list item, or move to the previous table cell. |
| Shift + F10 | In a table, open the row, column or table menu for the selection. |
| Cmd/Ctrl + / | Open the block menu for the caret's block (as Shift + F10 does outside a table); Copy link and Copy as Markdown there confirm with a toast. |
| / | Open the slash menu. |
| Alt + Enter | With the caret in (or at either end of) a comment highlight, open its thread (onAnnotationClick); elsewhere, unchanged. |
| Tab (read-only) | Move to the next comment highlight (with onAnnotationClick); each highlight is one tab stop. |
| Enter / Space (read-only) | On a focused comment highlight, open its thread (onAnnotationClick). |
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, empty, error, invalid, pressed, read-only, markdown, uploading, mention-menu, annotated, annotation-count, escape-blur, mark-saved |
| Accessibility | described, disabled, invalid, labeled, semantic-html, focus-visible, keyboard-operable |
| Visual | default, invalid, error, empty, active |