Source: https://monarchui.dev/components/rich-text-editor/

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/editor

Component

## Document notes

## 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

```tsx
import { RICH_TEXT_DEFAULT_TOOLBAR, RichTextEditor } from '@rookcom/monarch-react/editor';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `RICH_TEXT_DEFAULT_TOOLBAR` | utility | client | – |
| `RichTextEditor` | component | client | `.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,

| Prop | Type | Description |
| --- | --- | --- |
| `autoFocus` | `boolean` | Focus the end of the document when the editor loads. |
| `className` | `string` |  |
| `defaultValue` | `RichTextValue` | Initial document when uncontrolled, in `format`. |
| `disabled` | `boolean` | Not editable, toolbar inert; the document stays visible. |
| `editorRef` | `Ref<RichTextEditorHandle>` | Imperative handle (focus, clear, insertText, getHTML, …). |
| `extensions` | `Extensions` | Extra Tiptap extensions the product owns (custom nodes, marks, shortcuts). Change identity only to rebuild. |
| `fileAccept` | `string` | `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. |
| `headingLevels` | `readonly RichTextHeadingLevel[]` | Heading levels offered and allowed. Default `[2, 3]`; `[]` removes headings. |
| `id` | `string` |  |
| `images` | `boolean` | Allow images in the document. Default: true when `onFiles` is set. |
| `invalid` | `boolean` | Danger border, and `aria-invalid` on the editing surface. |
| `maxHeight` | `import("/home/runner/work/monarch/monarch/node_modules/csstype/index").Property.MaxHeight<string \| number>` | The text area scrolls above this height. Default none. |
| `maxLength` | `number` | Typing stops at this many characters. |
| `mentions` | `RichTextMentionOptions` |  |
| `messages` | `Partial<RichTextEditorMessages>` |  |
| `minHeight` | `import("/home/runner/work/monarch/monarch/node_modules/csstype/index").Property.MinHeight<string \| number>` | Minimum height of the text area. Default 6rem. |
| `name` | `string` | Submits 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. |
| `onFiles` | `RichTextFileHandler` | Upload 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`. |
| `placeholder` | `string` | Shown in the empty document. |
| `readOnly` | `boolean` | Shows the document without the toolbar; nothing can be changed. |
| `showCharacterCount` | `boolean` | Show the character counter. Default: shown when `maxLength` is set. |
| `taskList` | `boolean` | Allow checklists. Default true. |
| `toolbar` | `false \| readonly RichTextToolbarItem[]` | Toolbar controls in order, or `false` for none. Default `RICH_TEXT_DEFAULT_TOOLBAR`. |
| `toolbarEnd` | `ReactNode` | Content 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. |
| `value` | `RichTextValue` | Controlled document, in `format`. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `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

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Loading | `data-loading on the root` | On the server and before hydration: a static placeholder of the same size, marked `aria-busy`. |
| Focused | `:focus-within` | Focus-ring border around the whole box, text or toolbar. |
| Active formatting | `aria-pressed="true" on a toolbar button` | Accent-tinted button for the marks and blocks at the caret. |
| Invalid | `data-invalid, aria-invalid on the textbox` | Danger border. |
| Disabled | `data-disabled, aria-disabled on the textbox and toolbar` | The text stays visible on a subtle surface; nothing can be changed. |
| Read-only | `data-readonly` | No border, no toolbar, no textbox role: the document reads like content. Links stay clickable. |
| Near or over the limit | `data-state="near" \| "over" on the counter` | Warning, then danger colour; the remaining count is announced in a polite status region. |
| Suggestions open | `aria-controls / aria-activedescendant on the textbox` | A 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

| Keys | Action |
| --- | --- |
| 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, E | Bold, italic, strikethrough, inline code. |
| ⌘/Ctrl+Alt+1–4 | Heading level (when allowed). |
| ⌘/Ctrl+Shift+7 / 8 / 9 | Numbered list, bulleted list, checklist. |
| ⌘/Ctrl+Shift+B | Quote. |
| ⌘/Ctrl+Alt+C | Code block. |
| ⌘/Ctrl+K | Opens the link popover (Enter applies, Escape closes). |
| ⌘/Ctrl+Z, ⌘/Ctrl+Shift+Z | Undo 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.

## Related components

- [MessageComposer](https://monarchui.dev/components/message-composer/): The reply box built on this editor, with send and attachments.
- [Input](https://monarchui.dev/components/text-field/): Textarea for plain text with no formatting.
- [MentionPicker](https://monarchui.dev/components/mention-picker/): The suggestion list used here; also mentions in a plain textarea.
- [FileUpload](https://monarchui.dev/components/file-upload/): Uploads outside the text.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react``@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 components | [Button](https://monarchui.dev/components/button/), [Field](https://monarchui.dev/components/form-field/), [Input](https://monarchui.dev/components/text-field/), [Popover](https://monarchui.dev/components/popover/), [MentionPicker](https://monarchui.dev/components/mention-picker/) |

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`.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/rich-text-editor; Cross-browser axe: /components/rich-text-editor |

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

## All example code

### Document notes

```tsx
import { RichTextEditor } from '@rookcom/monarch-react/editor';

const NOTES = `<h2>Release checklist</h2><p>Ship the <strong>new onboarding</strong> after the review on Thursday. See the <a href="https://example.com/guide">rollout guide</a>.</p><ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Update the help article</p></li><li data-type="taskItem" data-checked="false"><p>Tell the support team</p></li></ul><blockquote><p>Keep the old flow behind a setting for one release.</p></blockquote>`;

<div style={{ width: '100%', maxWidth: 560 }}>
  <RichTextEditor
    aria-label="Release notes"
    defaultValue={NOTES}
    placeholder="Write notes…"
  />
</div>
```

### Controlled with a limit

```tsx
import { useState } from 'react';
import { RichTextEditor } from '@rookcom/monarch-react/editor';

function ControlledDemo() {
  const [html, setHtml] = useState('<p>Edit me, then look below.</p>');
  return (
    <div style={{ display: 'grid', gap: 8, width: '100%' }}>
      <RichTextEditor
        aria-label="Summary"
        value={html}
        onValueChange={(v) => setHtml(String(v))}
        toolbar={['bold', 'italic', 'code', '|', 'bulletList', 'orderedList', '|', 'link']}
        maxLength={280}
        minHeight="4rem"
      />
      <code style={{ fontSize: 12, overflowWrap: 'anywhere' }}>{html || '(empty)'}</code>
    </div>
  );
}

<div style={{ width: '100%', maxWidth: 560 }}>
  <ControlledDemo />
</div>
```

### Mentions and uploads

```tsx
import { RichTextEditor, type RichTextMentionItem } from '@rookcom/monarch-react/editor';

const PEOPLE: RichTextMentionItem[] = [
  { id: 'p1', label: 'Alex Morgan', description: 'Design' },
  { id: 'p2', label: 'Sam Rivera', description: 'Engineering' },
  { id: 'p3', label: 'Jordan Lee', description: 'Support' },
  { id: 'p4', label: 'Priya Shah', description: 'Product', disabled: true },
];

const searchPeople = (text: string) =>
  PEOPLE.filter((p) => p.label.toLowerCase().includes(text.toLowerCase()));

<div style={{ width: '100%', maxWidth: 560 }}>
  <RichTextEditor
    aria-label="Comment"
    placeholder="Type @ to mention someone, or paste an image"
    mentions={{ search: searchPeople }}
    onFiles={(files) =>
      files.map((f) => ({
        type: 'link' as const,
        href: `https://files.example.com/${encodeURIComponent(f.name)}`,
        text: f.name,
      }))
    }
    headingLevels={[]}
    minHeight="4rem"
  />
</div>
```

### Read-only and disabled

```tsx
import { RichTextEditor } from '@rookcom/monarch-react/editor';

<div style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 560 }}>
  <RichTextEditor
    aria-label="Published notes"
    readOnly
    defaultValue="<p>Read-only content keeps its <strong>formatting</strong> and <a href='https://example.com'>links</a>, without a toolbar.</p>"
  />
  <RichTextEditor
    aria-label="Locked description"
    disabled
    minHeight="3rem"
    defaultValue="<p>This description is locked while the review is open.</p>"
    toolbar={['bold', 'italic', 'link']}
  />
</div>
```
