# Rich Text Editor

> A React rich text editor on Tiptap with a toolbar, Markdown shortcuts, mentions, images and counts that outputs HTML, JSON or Markdown.

- Type: React component (Forms)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add rich-text-editor`
- Docs: https://wingo-ui.com/components/rich-text-editor

## Interaction

Type in the box; Markdown shortcuts format as you write (# and a space for a heading, - for a list, [] for a checklist, > for a quote, three backticks for code, **word** for bold), and pasted Markdown formats too. The toolbar has undo and redo, a text style menu (Paragraph, Heading 1 to 3), bold, italic, underline, strikethrough, inline code, link, the three lists, quote, code block, divider and image; the pressed state follows the caret and each button's tooltip shows its shortcut (⌘B on a Mac, Ctrl+B elsewhere). Below 640px the bar keeps undo, redo, style, bold, italic, link and the two lists and moves the rest into a ⋯ menu. Select text with a mouse and a small menu floats above it with bold, italic, strikethrough, code and link. Keys inside the editor: mod+b, mod+i, mod+u, mod+shift+s, mod+e, mod+alt+1 to 3 for headings, mod+alt+0 for a paragraph, mod+shift+7 / 8 / 9 for lists, mod+z and shift+mod+z, mod+k opens the link editor at the caret (Enter applies, an empty address removes the link, addresses without a scheme get https:// and javascript: is refused), Tab and Shift+Tab indent list items, alt+F10 jumps to the toolbar where Left and Right move between buttons, Home and End jump, and Escape returns to the text. Type @ to mention a colleague: the list follows the caret, arrows move, Enter or Tab picks, Escape closes, and the person becomes a chip. The image button opens a file picker (or asks for an address without an upload handler); pasted and dropped images upload with a progress bar in place and in the footer, and a failure removes the placeholder and is announced. With a character limit the counter turns amber at 90% and red at the limit, and typing stops there. On a phone the text is 16px so iOS never zooms, and while you type the toolbar docks above the keyboard as a horizontally scrolling rail of 44px buttons; the style menu, the ⋯ menu and the link editor open as bottom sheets. Read-only shows the same styles with no toolbar, links open in a new tab and checkboxes are locked.

## Installation

```bash
npx wingo-ui@latest add rich-text-editor
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/rich-text-editor` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/rich-text-editor.tsx`

npm dependencies: `npm i @tiptap/react @tiptap/pm @tiptap/core @tiptap/starter-kit @tiptap/extensions @tiptap/extension-list @tiptap/extension-image @tiptap/extension-mention @tiptap/suggestion @tiptap/markdown @radix-ui/react-popover motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [Separator](https://wingo-ui.com/components/separator) ([Markdown](https://wingo-ui.com/components/separator.md))
- [Toggle](https://wingo-ui.com/components/toggle) ([Markdown](https://wingo-ui.com/components/toggle.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { RichTextEditor, type RichTextValue } from "@/components/ui/rich-text-editor"

const team = [
  { id: "mihai-ionescu", label: "Michael Reed", description: "Sales" },
  { id: "ioana-dumitrescu", label: "Julia Davis", description: "Accounting" },
]

export function ClientNote() {
  const [note, setNote] = useState<RichTextValue>("<h2>Meeting, Sep 24, 2026</h2><p>The quote for <strong>Tech Solutions LLC</strong>.</p>")
  return (
    <RichTextEditor
      label="Client note"
      value={note}
      onValueChange={setNote}
      placeholder="Write a note…"
      showCount="both"
      characterLimit={2000}
      mentions={{ items: (q) => team.filter((p) => p.label.toLowerCase().includes(q.toLowerCase())) }}
      onImageUpload={async (file) => URL.createObjectURL(file)}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `string \| JSONContent` |  | The controlled content in `format`: an HTML string, a Markdown string, or Tiptap JSON. An empty editor is "" (HTML and Markdown). A value that differs from the last one the editor emitted (a reset, a load) replaces the content; echoing the emitted value back never resets the caret. |
| `defaultValue?` | `string \| JSONContent` | `""` | The starting content while uncontrolled. A form reset puts it back. |
| `onValueChange?` | `(value: string \| JSONContent, editor: Editor) => void` |  | Fires updateDebounce ms after the last change (and at once when the editor loses focus), with the content serialized in `format`. |
| `format?` | `"html" \| "json" \| "markdown"` | `"html"` | What value, defaultValue and onValueChange speak. markdown parses and writes Markdown through @tiptap/markdown; switching at runtime carries the current document over. |
| `updateDebounce?` | `number` | `150` | Milliseconds between the last keystroke and onValueChange, so serializing a long document never slows typing. 0 fires on every change. |
| `placeholder?` | `string` | `"Write something…"` | Shown while the editor is empty. An empty heading shows labels.headingPlaceholder instead. |
| `toolbar?` | `boolean \| RichTextToolbarItem[]` | `true` | true shows DEFAULT_TOOLBAR, false hides it (keys and Markdown shortcuts still work), or pass the items in order with "\|" between groups: "undo", "redo", "heading", "paragraph", "bold", "italic", "underline", "strike", "code", "bulletList", "orderedList", "taskList", "link", "blockquote", "codeBlock", "horizontalRule", "image". |
| `toolbarPosition?` | `"top" \| "bottom"` | `"top"` | bottom puts the toolbar under the text, the comment box look. |
| `stickyToolbar?` | `boolean` | `true` | The top toolbar sticks under the page's top bar (top: var(--topbar-offset, 0px)) while a long document scrolls with the page, and turns to frosted glass once stuck. |
| `bubbleMenu?` | `boolean` | `true` | A small menu above selected text (bold, italic, strikethrough, code, link) on mouse and trackpad screens. Phones keep their own selection menus. |
| `markdownShortcuts?` | `boolean` | `true` | Formats as you type (# , - , [] , > , ```, **bold**) and when Markdown-looking text is pasted. Off: every character stays literal. |
| `headingLevels?` | `(1 \| 2 \| 3 \| 4)[]` | `"[1, 2, 3]"` | The headings the text style menu offers and the schema allows. |
| `characterLimit?` | `number` | `0` | Stops input at this many characters and drives the counter's warning colors. 0 is no limit. |
| `showCount?` | `"none" \| "characters" \| "words" \| "both"` | `"none"` | The footer counter, in the locale's numbers ("1.240 caractere · 212 cuvinte"); with a limit it reads "1.240 / 2.000" and turns amber at 90% and red at 100%, and a screen reader hears it only when it crosses those two marks. |
| `minHeight?` | `number` | `160` | Minimum height of the text area in px. Before the editor mounts, the box already has this height, so nothing jumps. |
| `maxHeight?` | `number` | `0` | Past this height in px the text scrolls inside the box. 0 grows with the content and the page scrolls. |
| `onImageUpload?` | `(file: File, options: { onProgress: (progress: number) => void; signal: AbortSignal }) => Promise<string>` |  | Uploads a picked, pasted or dropped image and resolves its URL. A dimmed preview with a progress bar holds the spot meanwhile; a rejection removes it and announces labels.uploadFailed. Without it the image button asks for an image address. |
| `imageAccept?` | `string` | `"image/*"` | Accepted image types, in the file input's accept syntax; pasted and dropped files are checked against it too. |
| `maxImageSize?` | `number` | `10485760` | Largest image in bytes (10 MB); bigger files are refused with labels.imageTooLarge. 0 accepts any size. |
| `mentions?` | `RichTextMentions ({ items: (query: string) => MentionItem[] \| Promise<MentionItem[]>; trigger?: string; onMention?: (item: MentionItem) => void })` |  | Turns on @mentions. items gets the text after the trigger ("@" by default); a promise is awaited with a 120ms pause between keystrokes. Rows show an Avatar, the name and a description; picked people become chips with data-id, styled by the exported MENTION classes. |
| `extensions?` | `AnyExtension[]` | `"[]"` | Extra Tiptap extensions, appended after the built-in ones. Read when the editor is created; a change in their names recreates it. |
| `autofocus?` | `boolean \| "start" \| "end"` | `false` | Focuses the editor once it mounts, with the caret at the start or the end. |
| `spellCheck?` | `boolean` | `true` | The browser's spell checking on the text. |
| `setApi?` | `(api: RichTextEditorApi) => void` |  | Called after mount with { editor, getHTML, getJSON, getMarkdown, getText, focus(position?), clear(), insertContent(content), isEmpty() }. editor is the Tiptap instance for anything else. |
| `label?` | `ReactNode` | `""` | The field label above the box; a click on it focuses the text. |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby. |
| `error?` | `ReactNode` | `""` | Replaces the description and marks the field invalid. |
| `required?` | `boolean` | `false` | The asterisk after the label and aria-required on the text. |
| `invalid?` | `boolean` | `false` | The invalid ring and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the box; the text cannot be edited or focused. |
| `readOnly?` | `boolean` | `false` | Renders the content with the same styles and no toolbar or bubble menu: links open in a new tab, checkboxes are locked, the box keeps its variant so a note still reads as a document. To show saved HTML without an editor, put the exported PROSE classes on its wrapper. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Text 14 / 15 / 16px (16px on phones at every size) with matching padding and toolbar buttons (28 / 32 / 40px). |
| `variant?` | `"soft" \| "outline" \| "ghost"` | `"outline"` | outline draws a hairline (a document editor reads best framed), soft is the filled field well, ghost is borderless for a page that is the document. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner radius of the box as a surface: 0, 12, 16 or 24px; full falls back to lg. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the pressed toolbar buttons, mention chips, checked boxes, the upload bar, the selection and the caret. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. |
| `locale?` | `string` | `""` | Locale of the counter's numbers (1,240 in en-US). Empty uses the UIProvider's, then the browser's. |
| `name?` | `string` | `""` | Adds a hidden input with the value in `format` (JSON stringified) for plain form posts. |
| `id?` | `string` |  | The id of the editable element, which the label points to. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, hideLabel. |
| `labels?` | `Partial<RichTextEditorLabels>` |  | Every string, English by default (DEFAULT_RICH_TEXT_EDITOR_LABELS): button names (bold "Bold", heading "Text style", paragraph "Normal text", heading1 to heading4, …), link editor (linkPlaceholder, openLink, removeLink, apply, invalidLink, imageUrl), uploads (uploading, uploaded, uploadFailed, imageTooLarge, imageType), counts as functions (characters(count, formatted), words(count, formatted), limit(count, max, formatted, formattedMax), limitNear(remaining, formatted)), limitReached, mentions, noMentions, searching, toolbar, moreFormatting, content (the text's name without a label). |
| `classNames?` | `Partial<Record<"field" \| "label" \| "description" \| "error" \| "counter" \| "box" \| "toolbar" \| "button" \| "heading" \| "content" \| "bubble" \| "link" \| "mentions" \| "footer" \| "count" \| "docked", string>>` |  | Extra classes for the parts. |
| `className?` | `string` |  | Extra classes for the box (the Field wrapper when a label, description or error is set). |

## Parts

### DEFAULT_RICH_TEXT_EDITOR_LABELS (constant)

Every English string the editor shows or announces: button names, the link editor, upload states, character and word counts, mentions. Spread it and override what you translate.

### DEFAULT_TOOLBAR (constant)

The toolbar that toolbar={true} shows: undo, redo | heading | bold, italic, underline, strike, code, link | lists and tasks | blockquote, code block, rule, image. Copy it to add or drop items.

### PROSE (constant)

The Tailwind classes that style the content (headings, lists, quotes, code, links, images). Put them on the wrapper of saved HTML to render it exactly like the editor, without loading Tiptap.

### MENTION (constant)

The classes of a mention chip, tinted by the tone. Use them on mentions in saved HTML so they match the editor.

### normalizeUrl (function)

Turns what people type into a safe link: keeps a scheme, makes emails mailto:, adds https:// to bare domains, and returns null for javascript:, data: and other unsafe or invalid input.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `string` |  | The typed address. |
| `returns?` | `string \| null` |  | The link to store, or null when it is refused. |

### RichTextToolbarItem (type)

A toolbar entry: "bold", "link", "image" and the rest, or "|" for a group divider.

### RichTextEditorLabels (type)

The shape of labels; counts and limits are functions that get the number and its formatted text.

## Examples

- **Markdown in and out**: format="markdown": the value is Markdown, typed shortcuts write it for you.
- **Email template**: A reduced toolbar on the borderless ghost variant, like a mail composer.
- **Comment box**: Toolbar at the bottom, @mentions from the team and mod+enter to post.
- **Read-only article**: The same content styles with no toolbar; links open in a new tab.
- **Character limit**: 280 characters: the counter turns amber at 90% and red at the limit.
- **On a phone**: While you type on a touch screen the toolbar docks above the keyboard as a scrolling rail.

## See also

- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Mention Input](https://wingo-ui.com/components/mention-input) ([Markdown](https://wingo-ui.com/components/mention-input.md))
- [Markdown](https://wingo-ui.com/components/markdown) ([Markdown](https://wingo-ui.com/components/markdown.md))
- [Presence](https://wingo-ui.com/components/presence) ([Markdown](https://wingo-ui.com/components/presence.md))
- [Comment Thread](https://wingo-ui.com/components/comment-thread) ([Markdown](https://wingo-ui.com/components/comment-thread.md))

---

Canonical: https://wingo-ui.com/components/rich-text-editor
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
