Document Editor
A blank page people write in: a title, a slash menu for blocks, @ mentions, a bar over every selection, and to-dos, toggles, images, tables and code. Built on Tiptap.
Installation#
Usage#
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
Anatomy#
import {
DocumentEditor,
DocumentEditorCanvas,
DocumentEditorHeader,
DocumentEditorIcon,
DocumentEditorHeaderActions,
DocumentEditorHeaderAction,
DocumentEditorTitle,
DocumentEditorMeta,
DocumentEditorMetaItem,
DocumentEditorContent,
DocumentEditorBubbleMenu,
DocumentEditorBubbleButton,
DocumentEditorBubbleSeparator,
DocumentEditorTableMenu,
} from "@/components/ui/document-editor"
<DocumentEditor>
<DocumentEditorCanvas>
<DocumentEditorHeader>
<DocumentEditorHeaderActions>
<DocumentEditorHeaderAction />
</DocumentEditorHeaderActions>
<DocumentEditorIcon />
<DocumentEditorTitle />
<DocumentEditorMeta>
<DocumentEditorMetaItem />
</DocumentEditorMeta>
</DocumentEditorHeader>
<DocumentEditorContent />
<DocumentEditorBubbleMenu>
<DocumentEditorBubbleSeparator />
<DocumentEditorBubbleButton />
</DocumentEditorBubbleMenu>
<DocumentEditorTableMenu />
</DocumentEditorCanvas>
</DocumentEditor>Title and header#
The title is a field of its own, not the first block, so it can never be deleted or turned into a list. It grows with its text and moves into the body on Enter. Above it, DocumentEditorHeaderActions rest hidden and fade in on hover,DocumentEditorIcon holds the page emoji, and DocumentEditorMeta lays out chips under the title.
Width#
width sets the measure: page holds a line to 65 characters, wide opens to 900px for tables and images, and full fills the container. Switching slides the measure instead of jumping.
Slash menu#
With slashCommandExtension(), a / at the start of a block or after a space opens the block menu at the caret. Typing filters it by title and keyword, arrows move, Enter or Tab inserts. Markdown shortcuts work too: #, -, [], >, ```.
Every block it offers, painted with SlashMenuList, the same list on its own for a picker of your own.
commands decides what the menu offers: filter the registry, rename it, or add a BlockCommand of your own.
Bubble menu#
Select text and the bar rises over it. Highlight offers four colours mixed from the status tokens. onComment adds a Comment button, and children land at the end of the bar: the slot for an Ask AI action.
To-do list#
Type [] and a space. Items nest with Tab, and a checked one fades to the muted ink with a line through it.
Toggle#
A summary line that opens to reveal the blocks inside. Whether it is open is saved with the document.
Image#
An empty image block offers upload and a link field; pasting or dropping a picture anywhere inserts one. Select an image to align it, drag its edges to resize it, and add a caption. Pass onUpload to store files instead of previewing them locally.
Table#
A stroked grid, a faint header row and resizable columns. Selected cells take an ink wash, and DocumentEditorTableMenu adds grips on the hovered row and column, bars that add a row or a column, and the table's options at its corner.
Columns, contents and link cards#
Columns put two or three stacks of blocks side by side and fold to one on phones. The contents block lists the page's headings live and jumps to them. A link card turns a URL into a calm card with its title and domain; paste a bare link on an empty line to make one.
Mentions#
Type @ for a list of people and pages, grouped, filtered as you type. The pick lands as a quiet chip: an @ before a person, the emoji before a page. A click on a page chip calls onOpen.
Code block and divider#
The code block shows its language and a copy button on hover. A divider takes a soft ring when selected, so it can be moved or deleted like any block.
Read-only#
editable={false} renders the same page for reading. The bubble and table menus stay out, the title is read-only and checkboxes stop toggling, so the composition can stay as it is.
Density#
compact suits a document inside an app screen, comfortable a page given the whole window. It scales the title, the body, the headings and every gap between blocks.
compact
comfortable
Labels#
Each part takes its strings where it is set up: the root's labels, the bubble's labels, the blocks' labels and the slash menu's commands and emptyLabel.
Word count#
useDocumentEditorStats returns the words, characters and minutes of reading of the live document, for a status bar anywhere inside the editor.
API reference#
DocumentEditor
The root: creates the Tiptap editor and shares it, the labels and the editable state with every part through context. Built in: StarterKit (headings 1 to 3, links that autolink and do not open on click, an ink drop line), the placeholder, smart typography, multicolour highlight and text alignment. Forwards native <div> props (except onChange and defaultValue); every part accepts className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | Content | - | Initial document: an HTML string or Tiptap JSON. Uncontrolled; key the editor by document id to load another one. |
| onChange | (editor: Editor) => void | - | Called on every edit with the live editor. Read editor.getJSON() or editor.getHTML() there, ideally debounced. |
| editable | boolean | true | Set false to render the document read-only. The bubble and table menus hide, the title turns read-only and checkboxes stop toggling. Toggles without remounting. |
| extensions | Extensions | - | More Tiptap extensions, after the built-in ones: slashCommandExtension(), blockHandleExtension(), ...documentEditorBlocks() and your own. Read when the editor mounts. |
| width | "page" | "wide" | "full" | "page" | The text measure: 65 characters, 900px, or the whole container with a deeper gutter. Changing it slides the measure. |
| density | "compact" | "comfortable" | "compact" | Type size and spacing: compact sets the body at 15px and the title at 34px, comfortable at 16px and 40px with more air between blocks. Falls back to the nearest DensityProvider. |
| labels | Partial<DocumentEditorLabels> | - | Rewords the root's strings: placeholder, titlePlaceholder, headingPlaceholder (followed by the level) and slashEmpty. Missing keys keep the English defaults. |
| Data attribute | Description |
|---|---|
| data-width | The resolved width: page, wide or full. |
| data-density | The resolved density: compact or comfortable. |
| CSS variable | Description |
|---|---|
| --doc-measure | The text column: 65ch (page), 56.25rem (wide) or 100% (full). |
| --doc-gutter | Side padding of the canvas: 16px on phones, 56px (compact) or 64px (comfortable) from sm, 96px for full from lg. |
| --doc-top | Space above the header: 64px compact, 96px comfortable. |
| --doc-text | Body size: 15px compact, 16px comfortable. |
| --doc-line | Body line height: 1.6 times the body size in compact, 1.65 in comfortable. |
| --doc-flow | Space between blocks. |
| --doc-item | Space between list items and between paragraphs of a quote. |
| --doc-h1, --doc-h2, --doc-h3 | Space above each heading level. |
| --doc-after-heading | Space between a heading and the block under it. |
| --doc-after-title | Space between the header and the body. |
| --doc-hr | Space above and below a divider. |
DocumentEditorCanvas
The page: centres the measure, pads the gutter and the top, and ends in a tail you can click to keep writing (it adds an empty paragraph if the last block is not one). Forwards <div> props.
DocumentEditorHeader
The block above the body that holds the icon, the header actions, the title and the meta row. Its hover and focus reveal the header actions. Forwards <div> props.
DocumentEditorIcon
A 72px emoji button for the page icon. Wire it to your own picker (a Popover, the Emoji Picker). Forwards <button> props; type defaults to "button".
DocumentEditorHeaderActions
A row of quiet actions above the title (Add icon, Add cover). Hidden at rest, it fades in while the header is hovered or focused, and always shows on touch screens. Forwards <div> props.
DocumentEditorHeaderAction
One header action: muted text with an optional 16px icon and a 40px hit area. Forwards <button> props.
DocumentEditorTitle
The title, a textarea that grows with its text and never takes a line break. Enter moves into the body (text after the caret goes with it), ArrowDown at the end moves to the first block, and ArrowUp at the top of the body comes back. Forwards <textarea> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | - | Controlled title. |
| defaultValue | string | "" | Initial title when uncontrolled. |
| onValueChange | (value: string) => void | - | Called with the new title; pasted line breaks become spaces. |
| placeholder | string | labels.titlePlaceholder | Shown while the title is empty ("Untitled"). |
| aria-label | string | "Title" | The accessible name of the field. |
DocumentEditorMeta
A wrapping row of chips under the title: the date, the author, a status. Its gap above follows the density. Forwards <div> props.
DocumentEditorMetaItem
One chip: a soft fill, muted text in tabular figures, a 14px icon or an xs avatar tucked to its edge. As a link or button it gains a hover fill, a focus ring and a 40px hit area.
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Renders your element (a link, a button) through Radix Slot instead of a <span>. |
DocumentEditorContent
The writing area: Tiptap's EditorContent with the document typography (foreground headings, body in the reading ink, lists, quotes, links, inline code, highlights). Forwards <div> props.
DocumentEditorBubbleMenu
The bar over a text selection: Turn into, bold, italic, underline, strikethrough, inline code, link (⌘K opens it from the keyboard too), highlight, then an optional Comment and your own buttons. Hidden in code blocks and while read-only. It is the floating Toolbar: same surface, controls, roving focus and tooltips, and it forwards the Toolbar props (size, className…).
| Prop | Type | Default | Description |
|---|---|---|---|
| onComment | (editor: Editor) => void | - | Adds a Comment button and calls it with the editor while the selection is still live. |
| children | React.ReactNode | - | Your own controls after the built-ins, such as an Ask AI DocumentEditorBubbleButton. |
| labels | Partial<DocumentEditorBubbleMenuLabels> | - | Rewords the bar: toolbar, turnInto, bold, italic, underline, strike, code, link, linkPlaceholder, applyLink, removeLink, highlight, highlightNone, comment. |
| options | BubbleMenuProps["options"] | { placement: "top", offset: 8 } | Floating UI options for Tiptap's BubbleMenu (placement, offset, flip, shift), merged over the defaults. |
| Data attribute | Description |
|---|---|
| data-side | "top" or "bottom": where the bar sits against the selection, so it grows from that edge. |
DocumentEditorBubbleButton
A ToolbarButton for your own bubble controls: a tooltip with its shortcut and a pressed state. Takes every ToolbarButton prop (caret, variant…).
| Prop | Type | Default | Description |
|---|---|---|---|
| tooltip* | string | - | The accessible label and tooltip. |
| shortcut | string | - | A keycap shown in the tooltip. Display only. |
| pressed | boolean | - | Marks the control active and sets aria-pressed. |
| Data attribute | Description |
|---|---|
| data-pressed | Present when pressed. |
DocumentEditorBubbleSeparator
A ToolbarSeparator between groups of bubble controls.
DocumentEditorTableMenu
The table's controls, shown while a cell is hovered or holds the caret: a grip on the column (insert left or right, sort, delete) and on the row (insert above or below, header row, delete), bars on the right and bottom edges that add a column or a row, and an options button at the corner (header row, header column, fit to width, delete table). Needs the table block.
| Prop | Type | Default | Description |
|---|---|---|---|
| labels | Partial<TableMenuLabels> | - | Rewords the controls: columnOptions, rowOptions, tableOptions, addColumn, addRow, addRowBefore, addRowAfter, deleteRow, headerRow, headerColumn, addColumnBefore, addColumnAfter, sortAscending, sortDescending, deleteColumn, fitWidth, deleteTable. |
documentEditorMentions(options)
The @ list and the mention chip. Renders as span[data-type="mention"] with data-id, data-kind, data-label and data-icon, and parses back.
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | (query: string) => MentionItem[] | - | Everything that can be mentioned: { id, label, kind: "person" | "page", avatar?, icon? }. Filtered by the start of any word. |
| onOpen | (item: MentionItem) => void | - | A click on a page chip. |
| onSelect | (item: MentionItem) => void | - | Called after a mention is inserted. |
| labels | Partial<DocumentEditorMentionLabels> | - | people, pages, empty, untitled. |
| char | string | "@" | The trigger character. |
documentEditorBlocks(options)
Returns the rich block extensions: taskList, details (toggle), image, table, columns, tableOfContents, linkCard and codeBlockChrome (the code block's language label and copy button, and the divider's selected state). Spread it into extensions.
| Prop | Type | Default | Description |
|---|---|---|---|
| onUpload | (file: File) => Promise<string> | URL.createObjectURL | Turns a picked, pasted or dropped image into a URL. The default only previews locally; pass your storage upload. |
| exclude | DocumentEditorBlockName[] | [] | Leaves blocks out by name. Their slash commands disappear with them. |
| labels | Partial<…> | - | The blocks' strings. Image: empty, upload, urlPlaceholder, embed, caption, resize, alignLeft, alignCenter, alignRight, fitWidth, remove. Toggle: expand, collapse. Code: copyCode, copied. |
| linkCardLabels | Partial<LinkCardLabels> | - | The link card's strings: empty, urlPlaceholder, embed, open, title. |
| tocLabels | { empty?: string; label?: string } | - | The contents block's empty message and the accessible name of its list. |
slashCommandExtension(options)
The / menu. Typed at the start of a block or after a space (never in code), it lists blocks grouped as Basic, Lists, Media and Advanced, filters as you type, runs on Enter or Tab and gives up after three characters with no match.
| Prop | Type | Default | Description |
|---|---|---|---|
| commands | (editor: Editor) => BlockCommand[] | getBlockCommands | The commands offered. Filter, reorder, rename or add your own. |
| emptyLabel | string | "No blocks match" | Shown while nothing matches. |
| char | string | "/" | The character that opens the menu. |
BlockCommand
One entry of the block registry. BLOCK_COMMANDS is the full list; getBlockCommands(editor) keeps the ones whose node the editor has. The same list feeds the slash menu and both Turn into menus.
| Prop | Type | Default | Description |
|---|---|---|---|
| id* | string | - | Stable key: text, heading1, quote, divider, bulletList, taskList, toggle, image, table, codeBlock… |
| title* | string | - | The row label. |
| description | string | - | One short line, shown as the row's hint. |
| group* | "basic" | "lists" | "media" | "advanced" | - | The section it lists under. |
| icon* | React.ComponentType<IconProps> | - | A Koala Icon. |
| keywords | string[] | - | Extra search terms ("todo", "h1"). |
| shortcut | string | - | The Markdown shortcut shown on the row ("#", "[]"). |
| turnInto | boolean | - | Offer it in Turn into. Text-like blocks only. |
| isAvailable | (editor: Editor) => boolean | - | Hide the command when its node is not registered. |
| isActive | (editor: Editor) => boolean | - | Is the current block already this type (the check in Turn into). |
| run* | (editor: Editor, range?: Range) => void | - | Runs it. range is the typed /query to delete first; absent from Turn into. |
SlashMenuList
The menu's list on its own, for a picker of your own. Forwards <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | BlockCommand[] | - | The commands to list. |
| activeIndex* | number | - | The highlighted row. |
| onActiveIndexChange | (index: number) => void | - | Called when the pointer moves over a row. |
| onSelect* | (command: BlockCommand) => void | - | Called when a row is clicked. |
| grouped | boolean | true | Group rows under their labels (labels show only with two or more groups). |
| emptyLabel | string | "No blocks match" | Shown when items is empty. |
| density | "compact" | "comfortable" | - | Row height; falls back to the nearest DensityProvider. |
| side | "top" | "bottom" | "bottom" | Which way it opens, for the enter motion. |
blockHandleExtension()
The block keyboard: ⌘⇧↑ and ⌘⇧↓ (Ctrl on Windows) move the current block, or a heading with its whole section when documentEditorHeadings() is on; ⌘D duplicates it.
documentEditorHeadings()
Gives every H1 to H3 a stable slug id (h-launch-plan), so links, an outline and the table of contents can target it, and lets the block keyboard move a heading with its section. readDocumentHeadings(doc) lists them; getSectionRange, moveSection, duplicateSection and deleteSection work on a heading and the blocks under it.
moveBlock, duplicateBlock, deleteBlock, insertBlockBelow
The block operations behind the keyboard, for your own controls: moveBlock(editor, pos, -1 | 1), duplicateBlock(editor, pos), deleteBlock(editor, pos), insertBlockBelow(editor, pos, above?). A move the schema refuses is a no-op.
useDocumentEditor
Returns the live Tiptap Editor from the nearest DocumentEditor, or null on the first client render.
useDocumentEditorStats
Returns { words, characters, readingMinutes } for the live document, updated on every change (reading time at 230 words a minute).
Constants
DOCUMENT_EDITOR_HIGHLIGHTS (the four highlight colours), BLOCK_COMMAND_GROUP_LABELS, and the defaults DEFAULT_DOCUMENT_EDITOR_LABELS and DEFAULT_BUBBLE_MENU_LABELS.