Rich Text Editor React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React rich text editor on Tiptap with a toolbar, Markdown shortcuts, mentions, images and counts that outputs HTML, JSON or Markdown.
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.
Rich Text Editor is a Pro React component in the React form components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add rich-text-editor, together with Avatar, Button, Dropdown Menu, Field, Input and 12 more and the npm packages @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 and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add rich-text-editorRich Text Editor is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import RichTextEditor from @/components/ui/rich-text-editor.
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
valuestring | JSONContentThe 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.
defaultValuedefault""string | JSONContentThe starting content while uncontrolled. A form reset puts it back.
onValueChange(value: string | JSONContent, editor: Editor) => voidFires updateDebounce ms after the last change (and at once when the editor loses focus), with the content serialized in `format`.
formatdefault"html""html" | "json" | "markdown"What value, defaultValue and onValueChange speak. markdown parses and writes Markdown through @tiptap/markdown; switching at runtime carries the current document over.
updateDebouncedefault150numberMilliseconds between the last keystroke and onValueChange, so serializing a long document never slows typing. 0 fires on every change.
placeholderdefault"Write something…"stringShown while the editor is empty. An empty heading shows labels.headingPlaceholder instead.
toolbardefaulttrueboolean | RichTextToolbarItem[]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".
toolbarPositiondefault"top""top" | "bottom"bottom puts the toolbar under the text, the comment box look.
stickyToolbardefaulttruebooleanThe 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.
bubbleMenudefaulttruebooleanA small menu above selected text (bold, italic, strikethrough, code, link) on mouse and trackpad screens. Phones keep their own selection menus.
markdownShortcutsdefaulttruebooleanFormats as you type (# , - , [] , > , ```, **bold**) and when Markdown-looking text is pasted. Off: every character stays literal.
headingLevelsdefault"[1, 2, 3]"(1 | 2 | 3 | 4)[]The headings the text style menu offers and the schema allows.
characterLimitdefault0numberStops input at this many characters and drives the counter's warning colors. 0 is no limit.
showCountdefault"none""none" | "characters" | "words" | "both"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.
minHeightdefault160numberMinimum height of the text area in px. Before the editor mounts, the box already has this height, so nothing jumps.
maxHeightdefault0numberPast 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.
imageAcceptdefault"image/*"stringAccepted image types, in the file input's accept syntax; pasted and dropped files are checked against it too.
maxImageSizedefault10485760numberLargest image in bytes (10 MB); bigger files are refused with labels.imageTooLarge. 0 accepts any size.
mentionsRichTextMentions ({ 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.
extensionsdefault"[]"AnyExtension[]Extra Tiptap extensions, appended after the built-in ones. Read when the editor is created; a change in their names recreates it.
autofocusdefaultfalseboolean | "start" | "end"Focuses the editor once it mounts, with the caret at the start or the end.
spellCheckdefaulttruebooleanThe browser's spell checking on the text.
setApi(api: RichTextEditorApi) => voidCalled after mount with { editor, getHTML, getJSON, getMarkdown, getText, focus(position?), clear(), insertContent(content), isEmpty() }. editor is the Tiptap instance for anything else.
labeldefault""ReactNodeThe field label above the box; a click on it focuses the text.
descriptiondefault""ReactNodeHelper text under the box, linked with aria-describedby.
errordefault""ReactNodeReplaces the description and marks the field invalid.
requireddefaultfalsebooleanThe asterisk after the label and aria-required on the text.
invaliddefaultfalsebooleanThe invalid ring and aria-invalid without a message.
disableddefaultfalsebooleanDims the box; the text cannot be edited or focused.
readOnlydefaultfalsebooleanRenders 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.
sizedefault"md""sm" | "md" | "lg"Text 14 / 15 / 16px (16px on phones at every size) with matching padding and toolbar buttons (28 / 32 / 40px).
variantdefault"outline""soft" | "outline" | "ghost"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.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner radius of the box as a surface: 0, 12, 16 or 24px; full falls back to lg.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Colors the pressed toolbar buttons, mention chips, checked boxes, the upload bar, the selection and the caret.
colordefault""stringAny CSS color; replaces the tone.
localedefault""stringLocale of the counter's numbers (1,240 in en-US). Empty uses the UIProvider's, then the browser's.
namedefault""stringAdds a hidden input with the value in `format` (JSON stringified) for plain form posts.
idstringThe id of the editable element, which the label points to.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, hideLabel.
labelsPartial<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).
classNamesPartial<Record<"field" | "label" | "description" | "error" | "counter" | "box" | "toolbar" | "button" | "heading" | "content" | "bubble" | "link" | "mentions" | "footer" | "count" | "docked", string>>Extra classes for the parts.
classNamestringExtra classes for the box (the Field wrapper when a label, description or error is set).
Parts
DEFAULT_RICH_TEXT_EDITOR_LABELS
constantEvery 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
constantThe 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
constantThe 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
constantThe classes of a mention chip, tinted by the tone. Use them on mentions in saved HTML so they match the editor.
normalizeUrl
functionTurns 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.
input*stringThe typed address.
returnsstring | nullThe link to store, or null when it is refused.
RichTextToolbarItem
typeA toolbar entry: "bold", "link", "image" and the rest, or "|" for a group divider.
RichTextEditorLabels
typeThe 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.
Dependencies
Files
- components/ui/rich-text-editor.tsx
FAQ
Is the React Rich Text Editor component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Rich Text Editor?
Run npx wingo-ui@latest add rich-text-editor. It also adds the Wingo UI items it builds on (Avatar, Button, Dropdown Menu, Field, Input and 12 more) and the npm packages @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 and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/rich-text-editor.
How does Rich Text Editor behave on phones?
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.
See also
Used in
More React form components
Articles using this component
- React Mentions: @People and #Tags That Survive Renames
Component guides9 min read
- React Rich Text Editor: Tiptap, Markdown and Mentions
Component guides9 min read