Skip to content

Components · Collaboration

RichTextEditor

A Tiptap rich-text editor: accessible toolbar, Markdown shortcuts, safe paste, links, checklists, mentions, uploads and a character limit.

@rookcom/monarch-react/editorComponent

Document notes

Write notes…

Loading editor…

Usage examples

Controlled with a limit

Mentions and uploads

Read-only and disabled

Purpose

Edits formatted text (notes, descriptions, replies, documents) with a toolbar and Markdown shortcuts, and hands the product HTML or JSON. It is the editing surface MessageComposer builds on.

Use when

  • People write more than a sentence and need headings, lists, links or code.
  • The product stores formatted text (HTML or Tiptap JSON) and renders it again later.
  • Mentions or inline images belong in the text itself.

Avoid when

  • Plain text is enough (a title, a short comment): use Textarea, which loads no editor.
  • The box sends messages: use MessageComposer, which adds Enter-to-send, attachments and drafts.
  • Only showing stored HTML that never changes, in a list of many items: render the sanitised HTML directly rather than one editor per row.

Design principles

  • The schema is the sanitiser: only nodes and marks the toolbar can produce survive a paste, so pasted documents cannot smuggle styles, scripts or event handlers in.
  • Every toolbar action has a keyboard path: Markdown shortcuts while typing, ⌘/Ctrl shortcuts, and the toolbar itself is one Tab stop with arrow keys.
  • The product owns data: search for mentions, uploads and storage are injected; the editor never fetches.
  • Quiet chrome: the toolbar uses muted icons with an accent tint for active formatting, so the text stays the focus.
  • Heavy code stays optional: it lives in the @rookcom/monarch-react/editor entry, the only one that imports Tiptap (an optional peer dependency), so apps that do not edit rich text never install or load ProseMirror.

API reference

import { RICH_TEXT_DEFAULT_TOOLBAR, RichTextEditor } from '@rookcom/monarch-react/editor';
ExportKindRenderingRoot class
RICH_TEXT_DEFAULT_TOOLBARutilityclient–
RichTextEditorcomponentclient.mn-rte-

Renders .mn-rich-text-editor with a toolbar row (__bar, __toolbar), the editing surface (__content > .mn-rich-text-editor__prose, the ProseMirror element with role="textbox"), and an optional counter (__footer). ref goes to the root; editorRef gives the imperative handle.

aria-label, aria-labelledby and aria-describedby are applied to the editing surface, not the root.

The handle (RichTextEditorHandle) exposes focus, clear, getHTML, getJSON, getText, isEmpty, setContent, insertText, insertContent and the Tiptap editor.

Import from @rookcom/monarch-react/editor and install the @tiptap/* peer dependencies (2.27.x) listed in the package. Styles are part of @rookcom/monarch-react/styles.css.

Generated from the TypeScript declarations of packages/react/src/components/collaboration/rich-text-editor.

Properties

format="html" (default) emits an HTML string, and "" for an empty document. HTML is what most APIs store and render, but it is a lossy, larger format and must be sanitised on the server before rendering.

format="json" emits Tiptap JSON. It round-trips losslessly (custom attributes, mention ids), is easy to diff and to transform on the server, but needs Tiptap (or a JSON renderer) to display.

A controlled value that differs from what the editor last emitted replaces the document (the caret moves to the end). Echoing back the emitted value does nothing, so typing never jumps.

onFiles(files, source) may return { type: "image", src, alt } or { type: "link", href, text } items (or a promise of them) to insert; return nothing when the product shows files elsewhere.

mentions.search(text, { trigger, signal }) is MentionSearch from @rookcom/monarch-react/collaboration; the list is its MentionPicker driven by useMentionPicker, so one product search serves both the textarea and the rich-text pickers.

Changing headingLevels, taskList, images, maxLength, the mention trigger or extensions rebuilds the editor (the text is kept, undo history is not). The placeholder and ARIA update without a rebuild.

RichTextEditor

A Tiptap rich-text editor themed by Monarch tokens: an accessible formatting toolbar, Markdown shortcuts, safe paste, links, checklists,

PropTypeDescription
autoFocusbooleanFocus the end of the document when the editor loads.
classNamestring
defaultValueRichTextValueInitial document when uncontrolled, in `format`.
disabledbooleanNot editable, toolbar inert; the document stays visible.
editorRefRef<RichTextEditorHandle>Imperative handle (focus, clear, insertText, getHTML, …).
extensionsExtensionsExtra Tiptap extensions the product owns (custom nodes, marks, shortcuts). Change identity only to rebuild.
fileAcceptstring`accept` for the file picker, e.g. `image/*`.
format"html" | "json"`html` (default) emits an HTML string, `''` when empty; `json` emits Tiptap JSON. JSON round-trips losslessly and is cheaper to diff; HTML is what most APIs store and render. Sanitise either on the server.
headingLevelsreadonly RichTextHeadingLevel[]Heading levels offered and allowed. Default `[2, 3]`; `[]` removes headings.
idstring
imagesbooleanAllow images in the document. Default: true when `onFiles` is set.
invalidbooleanDanger border, and `aria-invalid` on the editing surface.
maxHeightimport("/home/runner/work/monarch/monarch/node_modules/csstype/index").Property.MaxHeight<string | number>The text area scrolls above this height. Default none.
maxLengthnumberTyping stops at this many characters.
mentionsRichTextMentionOptions
messagesPartial<RichTextEditorMessages>
minHeightimport("/home/runner/work/monarch/monarch/node_modules/csstype/index").Property.MinHeight<string | number>Minimum height of the text area. Default 6rem.
namestringSubmits the HTML with a form under this name.
onEditorKeyDown((event: KeyboardEvent, editor: Editor) => boolean | undefined)Runs before the editor's own key handling. Return true to stop it, for example Enter-to-send. Not called while a suggestion list is open.
onFilesRichTextFileHandlerUpload hook for pasted, dropped and picked files. Adds the toolbar's file button.
onValueChange((value: RichTextValue, details: RichTextChangeDetails) => void)Fires on every change with the document in `format`.
placeholderstringShown in the empty document.
readOnlybooleanShows the document without the toolbar; nothing can be changed.
showCharacterCountbooleanShow the character counter. Default: shown when `maxLength` is set.
taskListbooleanAllow checklists. Default true.
toolbarfalse | readonly RichTextToolbarItem[]Toolbar controls in order, or `false` for none. Default `RICH_TEXT_DEFAULT_TOOLBAR`.
toolbarEndReactNodeContent at the trailing end of the toolbar row, outside the toolbar's arrow-key group.
toolbarPosition"top" | "bottom"Toolbar above (default) or below the text.
valueRichTextValueControlled document, in `format`.

Variants

PropValuesDescription
toolbar'RICH_TEXT_DEFAULT_TOOLBAR''a custom list''false'Which controls appear, in order, with "|" for separators. Controls whose feature is off (no headings, no onFiles) are left out.
toolbarPosition'top''bottom'Above the text (documents) or below it (replies and composers).
format'html''json'The shape of value, defaultValue and onValueChange.

States

StateExposed asBehaviour
Loadingdata-loading on the rootOn the server and before hydration: a static placeholder of the same size, marked aria-busy.
Focused:focus-withinFocus-ring border around the whole box, text or toolbar.
Active formattingaria-pressed="true" on a toolbar buttonAccent-tinted button for the marks and blocks at the caret.
Invaliddata-invalid, aria-invalid on the textboxDanger border.
Disableddata-disabled, aria-disabled on the textbox and toolbarThe text stays visible on a subtle surface; nothing can be changed.
Read-onlydata-readonlyNo border, no toolbar, no textbox role: the document reads like content. Links stay clickable.
Near or over the limitdata-state="near" | "over" on the counterWarning, then danger colour; the remaining count is announced in a polite status region.
Suggestions openaria-controls / aria-activedescendant on the textboxA listbox of mention suggestions under the caret.

Interaction behaviour

  • Markdown shortcuts while typing: #–#### + space for headings (only the allowed levels), - or * for bullets, 1. for numbers, [ ] for a checklist, > for a quote, three backticks for a code block, and **bold**, *italic*, ` code , ~~strike~~` inline.
  • Toolbar buttons keep the selection (they do not take focus on mouse down) and toggle the mark or block at the caret.
  • The link button and ⌘/Ctrl+K open a popover with an address field. Bare domains get https://, email addresses get mailto:; javascript: and other schemes are refused with a message. Empty clears the link.
  • Pasted HTML is cleaned (Office markup, style and script blocks removed) and then parsed against the schema; links keep only http, https, mailto and relative targets. URLs typed or pasted become links automatically.
  • Pasted or dropped files, and files from the toolbar’s file button, go to onFiles; what it returns is inserted at the caret.
  • Typing the mention trigger (default @) searches mentions.search after a short pause; the request is aborted when the query changes. Choosing a suggestion inserts a mention chip.
  • With maxLength, input that would pass the limit is refused (whole insertions, not truncated).
  • Clicking the empty space below the text puts the caret at the end.

Keyboard support

KeysAction
Tab (into the toolbar)Focuses the last-used toolbar button.
ArrowLeft / ArrowRight (toolbar)Moves between buttons, wrapping at the ends.
Home / End (toolbar)First or last button.
Enter / Space (toolbar)Runs the button.
⌘/Ctrl+B, I, Shift+S, EBold, italic, strikethrough, inline code.
⌘/Ctrl+Alt+1–4Heading level (when allowed).
⌘/Ctrl+Shift+7 / 8 / 9Numbered list, bulleted list, checklist.
⌘/Ctrl+Shift+BQuote.
⌘/Ctrl+Alt+CCode block.
⌘/Ctrl+KOpens the link popover (Enter applies, Escape closes).
⌘/Ctrl+Z, ⌘/Ctrl+Shift+ZUndo and redo.
Tab / Shift+Tab (in a list)Indents or outdents the item.
ArrowDown / ArrowUp (suggestions)Moves the highlight, skipping unavailable people.
Enter / Tab (suggestions)Inserts the highlighted mention.
Escape (suggestions)Closes the list and keeps the typed text.

Outside lists Tab leaves the editor, so keyboard users are never trapped in the text.

Accessibility

Role
textbox (aria-multiline) with a toolbar; listbox for suggestions
Accessible name
aria-label or aria-labelledby on the component, applied to the textbox. The toolbar is named by messages.toolbar (default "Formatting").
  • The toolbar follows the APG toolbar pattern: one Tab stop with a roving tabindex, arrow keys, Home and End; toggles expose aria-pressed; groups are split by role="separator".
  • Every toolbar button has a name that includes its shortcut ("Bold (⌘B)") and a tooltip.
  • The link field is a labelled input in a popover; focus moves into it and returns to the text when it closes.
  • Mentions follow the APG combobox pattern: focus stays in the text, which points at the highlighted option with aria-activedescendant; loading, empty and error states are announced in a status region.
  • The counter is linked to the textbox with aria-describedby; the remaining count is announced politely only near the limit, not on every keystroke.
  • Read-only content has no textbox role, so screen readers read it as text; give it an aria-label to expose it as a named group.
  • Disabled sets aria-disabled; disabled and read-only both set contenteditable="false".

Responsive behaviour

  • The toolbar wraps onto a second row in narrow containers; buttons stay 32px square, so they remain easy to hit at 320px.
  • The text area grows with its content from minHeight (default 6rem) and scrolls past maxHeight.
  • Long words, URLs and code wrap or scroll inside the box instead of widening the page.
  • The suggestion list stays inside the viewport horizontally.

Theme support

  • Only semantic tokens: --mn-color-bg-surface, --mn-color-border-input, --mn-color-focus-ring, --mn-color-accent-subtle/-strong (active buttons, mentions), --mn-color-bg-muted (code), --mn-color-text-link. Light, dark, high-contrast and product accents apply automatically.
  • In forced-colours mode active buttons and mentions get a system-colour outline.
  • Transitions and the suggestion list animation are removed under prefers-reduced-motion.

Do and don't

Do

  • Sanitise the HTML on the server before storing or rendering it; the editor never produces unsafe markup, but requests can be forged.
  • Store JSON when the product transforms documents on the server or needs mention ids; store HTML when it only renders them.
  • Offer only the formatting the product renders: pass a shorter toolbar and headingLevels={[]} for comments.
  • Return inserts from onFiles only after the upload has a permanent URL.

Don't

  • Do not render one editor per row of a long list; render stored HTML and open an editor on demand.
  • Do not rebuild the editor on every render by passing a new extensions array; memoise it.
  • Do not use blob: or data: URLs as permanent image sources.
  • MessageComposer: The reply box built on this editor, with send and attachments.
  • Input: Textarea for plain text with no formatting.
  • MentionPicker: The suggestion list used here; also mentions in a plain textarea.
  • FileUpload: Uploads outside the text.

Dependencies

KindDepends on
npm packagesreact@tiptap/react@tiptap/starter-kit@tiptap/extension-link@tiptap/extension-placeholder@tiptap/extension-character-count@tiptap/extension-task-list@tiptap/extension-task-item@tiptap/extension-image@tiptap/extension-mention
Monarch componentsButton, Field, Input, Popover, MentionPicker

Button, Field, Input and Popover make the link popover; useMentionPicker + MentionPicker the suggestion list. Tiptap ^2.27.3 as optional peer dependencies of @rookcom/monarch-react (tested with 2.27.3); @tiptap/core and @tiptap/suggestion are type-level and peer dependencies of the extensions.

Bundle cost, measured with esbuild (minified, react, react-dom and Radix external): import { RichTextEditor } from "@rookcom/monarch-react/editor" is about 124 KB min+gzip, of which about 117 KB is ProseMirror and Tiptap core; the rest is the Monarch components it composes, shared with the root entry. Unused Tiptap code (bubble and floating menus, tippy.js) is tree-shaken away, and the root entry contains no Tiptap. Mentions, checklists, images and the counter add about 4 KB and are always bundled with the editor, but only enter the schema when enabled.

Testing coverage

  • Labelled multi-line textbox; toolbar buttons, names and one Tab stop; arrows, Home and End move focus with wrapping.
  • Toolbar toggles marks and blocks and reflects aria-pressed; Markdown and ⌘/Ctrl shortcuts while typing.
  • Uncontrolled defaultValue, controlled value replaced by the product, JSON format in and out, "" for an empty document.
  • Read-only (no toolbar, no textbox), disabled (inert toolbar, not editable), placeholder, invalid.
  • Character counter linked to the textbox, near-limit announcement, refused insertions past maxLength.
  • Link popover from ⌘/Ctrl+K: refuses javascript:, normalises a bare domain, adds rel.
  • Paste drops scripts, handlers, styles, Office markup and unsafe links and images.
  • onFiles receives picked files and its image and link inserts land in the document.
  • Mentions: injected search, listbox with aria-activedescendant, disabled people skipped, Enter inserts a mention node, Escape dismisses.
  • onEditorKeyDown runs before the editor (Enter suppressed); hidden form input for name.
CheckCovered
Axe in jsdomYes
Server renderingYes
Browser suitesVisual baselines: /__gallery/rich-text-editor; Cross-browser axe: /components/rich-text-editor

Tests: packages/react/src/components/collaboration/rich-text-editor