Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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-edit

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/text-edit

Run 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:

CapabilityWhy it's out of the baseWhere it lives
Image upload / paste-to-uploadNeeds app storage (R2/CDN)App resolves URLs (same split as Image)
@mentionsNeeds the app's user/entity dataApp-supplied data source + query
Emoji picker, syntax highlightingComposed Tiptap extensionsFuture composed addons
CalloutsNo clean CommonMark/GFM form to round-trip toA quote, or a MarkdownView-only extension
Real-time collaboration (multi-cursor, awareness, Yjs CRDT)Needs @tiptap/extension-collaboration + yjs + a sync backendSeparate 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:

ImageViewerScope — data-slot="image-viewer-scope"
TextEdit — data-slot="text-edit" | "text-edit-actions" | "text-edit-content" | "text-edit-footer" | "text-edit-leading"
TextEditEditor — data-slot="text-edit-content" | "text-edit-slash-icon"

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) — in document mode transparent and borderless: it takes the surface it sits on. In boxed mode a bordered field whose border darkens subtly while the editor holds focus and turns destructive when invalid. Carries data-editable and data-invalid when it wraps an aria-invalid control. Clicking its blank space focuses the end of the document.
  • Bubble menu (data-slot="text-edit-bubble-menu", role="toolbar") — a Turn into button, Toggle buttons 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 a DropdownMenu and 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 shared SuggestionMenu that TemplateEditor's picker also opens: rows exactly like ComboboxItem's (the accent highlight, 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 as MarkdownView in 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/typography dependency. It has no fill, border or ring in any state — in document mode the caret is the focus cue, in boxed mode 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.

View

Heading 1

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

A paragraph with bold, italic, strikethrough, inline code and a link (opens in new tab). A hard line break follows,
then https://example.com (opens in new tab) autolinks in view mode.

  • Bullet item
  • Another item
    • Nested item
  1. First
  2. Second
    1. Nested ordered
  • Done task
  • Open task
    • Nested task

A blockquote carries a quoted thought.

TypeScript
const answer = 42;

NameRole
AdaEngineer
GraceAdmiral
VegaStack mark
Edit

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-resize cursor, 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 in format="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.

Plan

Seats

Price

Starter

5

Free

Team

25

$12

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 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", below floatingSafeArea.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 from emoji-data, loaded on first use. The pick lands as the character itself. 10:30, Note: and https:// 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>). scrollToHeading takes 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 and keywords. onSelect gets insertMention, the same chip the @ menu inserts, and insertContent(json) for a block. A disabled item shows its disabledReason and 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, or null for 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, a value change or the host's own setTaskItemChecked.
  • 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 calls onMakeTask with each line's key (its blockId, given one if it has none) and trimmed text. Create the tasks, then call handleRef.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 a TaskInline — 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's assignee, due and overdue, else the InlineChipProvider's state), and ↗ to the task's page (mentionHref) shows on hover. The read view and MarkdownView take 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 a MakeTaskPopover at 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. Like mentionLabel, it is read at render, and chips re-render when the function changes. The read view and MarkdownView take the same prop.
  • Section marks (sectionMarks): each { key, tone, label } names a heading by its blockId. It draws a dot in the gutter beside the heading, 32px before the text (50px on touch), clear of the drag grip, and a warning or info bar 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 by label (also its tooltip) that calls onSectionMark(key). Headings carry a blockId in a shared document or in HTML with data-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 onSubmit keeps 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: onImageUpload and onFileUpload get onProgress(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, and bubbleActions({ 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.

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 (or null at once for another site's link). The chip replaces the URL when it answers, unless the text was edited meanwhile; an answer of null leaves 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's linkPreview, 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 (![alt|WxH](src), 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, ![alt](src "caption"), 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: ![alt|320](src) 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.

Every page goes through review before it is published.

Diagram
flowchart LR
  accTitle: Publishing a page
  accDescr: A draft goes to review, then is published or sent back for changes.
  draft[Draft] --> review{Review}
  review -->|Approved| published([Published])
  review -->|Changes| draft
  class draft step
  class review decision
  class published outcome

The reviewer is the space's editor.

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

    Open thread: 25 A breaker

    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

    PropTypeDefaultDescription
    actionsReact.ReactNode—variant="composer": the trailing controls (Send), bottom-right.
    activeAnnotationIdstring | nullnullThe 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.
    annotationsreadonly 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-describedbystring—Id(s) of helper or error text describing the editor.
    aria-invalid"false" | "grammar" | "spelling" | "true" | boolean—Marks the textbox invalid.
    aria-labelstring—Accessible label for the editable region.
    aria-labelledbystring—Id(s) of the element(s) that label the editable region.
    autosavenumber | booleanfalseAlso call onCommit after this many milliseconds without typing (true is 1000ms).
    blockViewsReadonly<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
    bookmarksbooleanfalseBookmarks: 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.
    childrenReact.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).
    classNamestring—Additional class names on the editor container.
    collaborationTextEditCollaboration—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).
    defaultValuestring''Uncontrolled initial content, used only on first render. Ignored when value is provided.
    diffTextEditDiffView—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.
    disabledbooleanfalseDisable the editor: the surface reports aria-disabled, and the root dims.
    dragHandlesbooleantrueShow 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.
    embedsPartial<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.
    emojibooleanfalseType <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.
    fileLinkPrefixstring"/api/files/"A link whose href starts with this renders as a file chip, here and in MarkdownView.
    findReplacebooleanfalseFind 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).
    footerReact.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.
    handleRefReact.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.
    idstring—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.
    inlineImageTypesreadonly 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.
    leadingReact.ReactNode—variant="composer": the leading controls (attach), bottom-left.
    liveBlockKindsreadonly LiveBlockKind[]DEFAULT_LIVE_BLOCK_KINDS — the live task list (`tasks`) aloneThe 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.
    maxHeightstring | 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.
    mentionsTextEditMentions—@ 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.
    minHeightstring | 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.
    placeholderstring—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).
    readOnlybooleanfalseRender the document without letting it be edited; the surface reports aria-readonly. Its images still open in the FileViewer on a click.
    refReact.Ref<HTMLDivElement>—Ref forwarded to the editor's root container <div>.
    renderLiveBlockLiveBlockRender—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.
    savingbooleanfalseMark the editor busy (aria-busy) while the host persists a commit.
    sectionMarksreadonly 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.
    slashActionsreadonly 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.
    slashCommandsreadonly 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.
    slashHintbooleantrueWhether 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.
    toggleStateTextEditToggleState—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.
    valuestring—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

    AttributeValues
    data-disabled""
    data-editable""
    data-invalid""
    data-placeholdermirrors a prop or state value
    data-slot"text-edit" | "text-edit-actions" | "text-edit-content" | "text-edit-footer" | "text-edit-leading"
    data-variantmirrors a prop or state value

    ImageViewerScope takes a div's props plus:

    PropTypeDefaultDescription
    disabledbooleanfalseLeave 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

    AttributeValues
    data-slot"image-viewer-scope"

    Accessibility

    • The editable surface is a role="textbox" with aria-multiline="true". Give it an aria-label (or associate a visible label) so the region is announced.
    • Use aria-invalid and aria-describedby to connect validation state and helper/error text to the contenteditable textbox.
    • The slash menu is a listbox of options with aria-selected; focus stays in the textbox, which points at it with aria-controls and at the active option with aria-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 a status (“No results”), not an empty listbox.
    • The bubble menu is a Base UI Toolbar labelled “Selection formatting”: one tab stop, arrow keys between its Toggles, each with an aria-label and aria-pressed. With onCreateAnnotation it leads with a Comment button; read-only, the toolbar (“Selection actions”) holds only that.
    • The mention menu is a listbox labelled “Mention” whose groups are named by their headings (People, Pages, Files, Tasks); focus stays in the textbox, as with the slash menu, with the same aria-activedescendant wiring. Its placeholder rows are hidden from assistive technology; when a search fails or finds nothing, the menu is a status (“Couldn't search”, “No matches”). A chip reads as its name.
    • The paste popover is a toolbar labelled “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 a group named “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 a role="button" tab stop named by annotationLabel (“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's accent tint. 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 a role="button" named by annotationCountLabel, out of the tab order.
    • Editing keymaps come from Tiptap StarterKit plus a few of ours — see the table below. In document mode focus is shown by the caret itself: the surface has no ring, border or tint (it declares data-focus-cue="caret"). In boxed mode 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) sets data-invalid on the root for downstream styling; pair it with visible error text through aria-describedby.
    • readOnly renders a document, not a field: the surface is role="document" with no aria-multiline or aria-readonly, so nothing announces a text box that cannot be typed in.
    • In collaboration mode a collaborator's caret and name are presence cues, hidden from assistive tech (aria-hidden); a toggle's chevron keeps aria-expanded for this viewer's own state.
    KeyAction
    Cmd/Ctrl + BToggle bold.
    Cmd/Ctrl + IToggle italic.
    Cmd/Ctrl + EToggle inline code.
    Cmd/Ctrl + Shift + XToggle strikethrough.
    Cmd/Ctrl + Shift + 7Numbered list (8 bullet, 9 checklist).
    Cmd/Ctrl + Shift + ↑Move the block up (↓ down).
    Cmd/Ctrl + ZUndo.
    Cmd/Ctrl + Shift + ZRedo.
    EnterNew paragraph (or new list item inside a list).
    Cmd/Ctrl + EnterFire 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.
    EscWith "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 + KOpen 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).
    TabIndent a list item, or move to the next table cell (the last cell adds a row); elsewhere, move focus out of the editor.
    Shift + TabOutdent a list item, or move to the previous table cell.
    Shift + F10In 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 + EnterWith 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).
    ContractStates tested
    Behaviourdefault, active, disabled, empty, error, invalid, pressed, read-only, markdown, uploading, mention-menu, annotated, annotation-count, escape-blur, mark-saved
    Accessibilitydescribed, disabled, invalid, labeled, semantic-html, focus-visible, keyboard-operable
    Visualdefault, invalid, error, empty, active

    Do / Don't

    Do
    Use TextEdit for short-to-medium rich text — comments, descriptions, release notes — and drive it with controlled value/onValueChange HTML.
    Don't
    Use it for plain single-line input (use Input), or pass value/onValueChange to an editor in collaboration mode — the shared document is the truth there.

    On this page