Rich Text Editor
A form-first TipTap editor with an accessible formatting toolbar and HTML or JSON values.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/rich-text-editor (pulls rich-text-internals). Pair reads with Rich Text.
Rich Text Editor is a compact TipTap authoring control for forms and detail pages: attached toolbar, HTML or JSON values, and a headless useRichTextEditor view-model. Ant Design and Arco Design do not provide the editor engine — their form and control patterns inform the surrounding UX only. Render saved content with Rich Text; do not treat client filtering as a server security boundary.
When To Use
- Collect formatted descriptions in forms where a plain
<textarea>is too limited. - You need HTML for ordinary form payloads, or JSON + schema version for durable structured persistence.
- Prefer Formily
kind="richText"/f.richTextso required validation uses semantic emptiness. - Use Markdown Renderer when Markdown is the canonical format instead of TipTap documents.
- For a full-viewport writing canvas (not the docs cards), open the Rich Text Editor showcase.
Features
| Area | Behavior |
|---|---|
| Formats | format="html" (default) or format="json" |
| Ownership | Controlled value / onValueChange or uncontrolled defaultValue |
| Toolbar | Attached ARIA toolbar with block menu, marks, lists, link popover, undo/redo |
| Emptiness | Semantically empty → empty string (HTML) or EMPTY_RICH_TEXT_JSON (JSON) |
| Change details | onValueChange(value, details) includes html, json, text, isEmpty, schemaVersion |
| i18n | en / zh-CN via useRichTextEditorI18n under I18nProvider |
| Headless | useRichTextEditor + named parts for custom chrome |
Interactions
| Event | Behavior |
|---|---|
| Type / paste | Updates the controlled value; IME composition is queued until confirm |
| Toolbar command | Applies mark/block while preserving selection (mousedown preventDefault) |
| Link popover | Validates URL protocols before applying a link mark |
| Blur | Default Formily validation trigger for kind="richText" |
Alt+F10 | Moves focus from the editor surface to the toolbar |
| Escape (toolbar / overlay) | Returns focus to the editor |
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/rich-text-editorFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/rich-text-editorFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/rich-text-editorFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/rich-text-editorregistryDependencies: @f-ui-plus/rich-text-internals, button, dropdown-menu, input, popover, tooltip, fui-i18n. Runtime: @tiptap/extension-placeholder, @tiptap/pm, @tiptap/react, lucide-react.
Usage
import { useState } from "react";
import { RichTextEditor } from "@/components/f-ui/rich-text-editor/rich-text-editor";
export function Page() {
const [value, setValue] = useState("<p></p>");
return (
<RichTextEditor
value={value}
onValueChange={setValue}
aria-label="Description"
placeholder="Start typing…"
/>
);
}HTML is the default form-interoperability format. Prefer JSON plus schema version when you need durable structured persistence and schema evolution.
Examples
HTML
Controlled HTML with a live Rich Text preview of the sanitized output.
Sanitized preview (Rich Text)
Write a short release note. Formatting updates the sanitized preview below.
"use client";
import { useState } from "react";
import { RichTextEditor } from "@/components/f-ui/rich-text-editor/rich-text-editor";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
const INITIAL =
"<p>Write a short release note. Formatting updates the sanitized preview below.</p>";
export function RichTextEditorDemo() {
const [value, setValue] = useState(INITIAL);
return (
<div className="space-y-4">
<RichTextEditor
value={value}
onValueChange={setValue}
aria-label="Body"
placeholder="Start typing…"
minHeight={160}
/>
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Sanitized preview (Rich Text)
</p>
<div className="border-input bg-card rounded-md border p-4">
<RichText value={value} />
</div>
</div>
</div>
);
}JSON Persistence
Own a JSONContent value, display RICH_TEXT_SCHEMA_VERSION, and render the same document with Rich Text.
Schema version: 1 — store this alongside JSON for durable persistence.
Rendered JSON (Rich Text)
Persist structured JSON with schema version 1.
"use client";
import { useState } from "react";
import { RichTextEditor } from "@/components/f-ui/rich-text-editor/rich-text-editor";
import { RICH_TEXT_SCHEMA_VERSION } from "@/components/f-ui/rich-text-internals/rich-text-schema";
import type { JSONContent } from "@/components/f-ui/rich-text-internals/rich-text-types";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
const INITIAL: JSONContent = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Persist structured JSON with schema version ",
},
{
type: "text",
text: String(RICH_TEXT_SCHEMA_VERSION),
marks: [{ type: "code" }],
},
{ type: "text", text: "." },
],
},
],
};
export function RichTextEditorJsonDemo() {
const [value, setValue] = useState<JSONContent>(INITIAL);
return (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
Schema version:{" "}
<code className="text-foreground">{RICH_TEXT_SCHEMA_VERSION}</code>
{" — "}
store this alongside JSON for durable persistence.
</p>
<RichTextEditor
format="json"
value={value}
onValueChange={setValue}
aria-label="JSON body"
placeholder="Structured document…"
minHeight={160}
/>
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Rendered JSON (Rich Text)
</p>
<div className="border-input bg-card rounded-md border p-4">
<RichText format="json" value={value} schemaVersion={RICH_TEXT_SCHEMA_VERSION} />
</div>
</div>
</div>
);
}Formily
FormField kind="richText" with required validation. Empty paragraphs map to undefined so Formily required means semantic content.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
interface BodyValues {
body?: string;
}
export function RichTextEditorFormFieldDemo() {
const form = useMemo(
() =>
createForm<BodyValues>({
initialValues: { body: undefined },
}),
[],
);
const [submitted, setSubmitted] = useState<BodyValues | null>(null);
return (
<div className="w-full max-w-xl space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="body"
label="Description"
description="Required — empty paragraphs do not count as content."
kind="richText"
required
componentProps={{
placeholder: "Write the description…",
minHeight: 140,
}}
/>
<FormActions>
<Button type="submit">Submit</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Article Form
A publish-style Formily page: title, category, visibility, optional plain summary, and a required rich-text body. Submit to inspect the payload — empty body paragraphs still fail required.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
interface ArticleValues {
title: string;
category: string;
visibility: string;
summary: string;
body?: string;
}
const CATEGORIES = [
{ label: "Docs", value: "docs" },
{ label: "Changelog", value: "changelog" },
{ label: "Announcement", value: "announcement" },
];
const VISIBILITY = [
{ label: "Public", value: "public" },
{ label: "Internal", value: "internal" },
];
export function RichTextEditorArticleFormDemo() {
const form = useMemo(
() =>
createForm<ArticleValues>({
initialValues: {
title: "",
category: "docs",
visibility: "public",
summary: "",
body: undefined,
},
}),
[],
);
const [submitted, setSubmitted] = useState<ArticleValues | null>(null);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<div className="flex flex-col gap-[var(--fui-form-field-gap)]">
<FormField
name="title"
label="Title"
required
kind="text"
componentProps={{ placeholder: "Shipping Rich Text Editor" }}
/>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] sm:grid-cols-2">
<FormField
name="category"
label="Category"
required
kind="select"
componentProps={{ options: CATEGORIES }}
/>
<FormField
name="visibility"
label="Visibility"
required
kind="select"
componentProps={{ options: VISIBILITY }}
/>
</div>
<FormField
name="summary"
label="Summary"
description="Optional plain-text teaser for lists and search."
kind="textarea"
componentProps={{
placeholder: "A short plain-text teaser…",
rows: 2,
}}
/>
<FormField
name="body"
label="Body"
description="Required — empty paragraphs do not count as content."
kind="richText"
required
componentProps={{
placeholder: "Write the article…",
minHeight: 160,
}}
/>
</div>
<FormActions>
<Button type="submit">
Publish
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Headless Usage
useRichTextEditor is the view-model: editor instance, command state, toolbar/link overlays, and prop bags for RichTextEditorRoot, RichTextEditorToolbar, and RichTextEditorContent. Stock toolbar chrome (RichTextEditorControl, block menu, link popover) is optional — the demo uses native <button> controls with no styling classes.
value: <p>Headless composition: hook as view-model, native buttons for commands.</p>
bold: false, italic: false, block: paragraph
"use client";
import { useState } from "react";
import { RichTextEditorContent } from "@/components/f-ui/rich-text-editor/rich-text-editor-parts/rich-text-editor-content";
import { RichTextEditorRoot } from "@/components/f-ui/rich-text-editor/rich-text-editor-parts/rich-text-editor-root";
import { RichTextEditorToolbar } from "@/components/f-ui/rich-text-editor/rich-text-editor-parts/rich-text-editor-toolbar";
import { useRichTextEditor } from "@/components/f-ui/rich-text-editor/use-rich-text-editor";
export function RichTextEditorHeadlessDemo() {
const [value, setValue] = useState(
"<p>Headless composition: hook as view-model, native buttons for commands.</p>",
);
const vm = useRichTextEditor({
value,
onValueChange: setValue,
"aria-label": "Headless body",
minHeight: 120,
});
return (
<div>
<RichTextEditorRoot {...vm.rootProps}>
<RichTextEditorToolbar
ref={vm.toolbarRef}
{...vm.toolbarProps}
onKeyDown={vm.onToolbarKeyDown}
>
<button
type="button"
aria-pressed={vm.state.bold}
disabled={!vm.editor}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.bold()}
>
Bold
</button>
<button
type="button"
aria-pressed={vm.state.italic}
disabled={!vm.editor}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.italic()}
>
Italic
</button>
<button
type="button"
aria-pressed={vm.state.underline}
disabled={!vm.editor}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.underline()}
>
Underline
</button>
<button
type="button"
aria-pressed={vm.state.bulletList}
disabled={!vm.editor}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.bulletList()}
>
Bullet list
</button>
<button
type="button"
disabled={!vm.state.canUndo}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.undo()}
>
Undo
</button>
<button
type="button"
disabled={!vm.state.canRedo}
onMouseDown={(event) => event.preventDefault()}
onClick={() => vm.commands.redo()}
>
Redo
</button>
</RichTextEditorToolbar>
<RichTextEditorContent editor={vm.editor} {...vm.contentProps} />
</RichTextEditorRoot>
<p>value: {value || "—"}</p>
<p>
bold: {String(vm.state.bold)}, italic: {String(vm.state.italic)}, block:{" "}
{vm.state.block}
</p>
</div>
);
}Composition
RichTextEditor
├── useRichTextEditor
└── RichTextEditorRoot
├── RichTextEditorToolbar
│ └── RichTextEditorDefaultToolbar
│ ├── RichTextEditorToolbarGroup
│ ├── RichTextEditorControl
│ ├── RichTextEditorBlockMenu
│ └── RichTextEditorLinkPopover
└── RichTextEditorContentKeyboard
| Shortcut | Behavior |
|---|---|
Alt+F10 | Focus the toolbar (active or first enabled control) |
| Arrow Left / Right | Roving focus across enabled toolbar controls |
| Home / End | First / last enabled toolbar control |
| Escape | From toolbar or overlay → return focus to the editor |
| Mod-B / Mod-I / Mod-U | Bold / italic / underline (platform modifier) |
| Tab in lists | Indent / outdent list items; ordinary paragraphs do not trap Tab |
Standalone usage requires aria-label or aria-labelledby. Formily FormItem supplies labeled-by / described-by automatically.
Security
| Threat | Default |
|---|---|
| Bypass the UI | Attackers can POST arbitrary HTML/JSON — validate and sanitize on the server |
| Editor HTML output | Schema-constrained, not “server-sanitized”; full sanitize is not run on every keystroke |
| Unsafe link schemes | Protocol checks reduce accidents; backends must still enforce allowlists |
| Custom extensions | Trusted app code; declare reader + sanitizer + migration parity |
Client filtering is not a server security boundary. Sanitize before persistence and again at any untrusted HTML render boundary; apply CSP independently. Prefer Rich Text for display.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
format | "html" | "json" | "html" | Value format. |
value | string | JSONContent | — | Controlled value. |
defaultValue | string | JSONContent | — | Uncontrolled initial value. |
onValueChange | (value, details) => void | — | Fires on document updates. |
placeholder | string | — | Empty editor hint. |
disabled | boolean | false | Disables editing and toolbar. |
readOnly | boolean | false | Hides toolbar; content is read-only. |
toolbar | boolean | true | Set false to omit the default toolbar. |
minHeight | number | string | — | Minimum height of the content surface. |
autoFocus | boolean | false | Focus the editor on mount. |
contentPolicy | "reject" | "normalize" | — | How invalid incoming content is handled. |
extensionConfig | RichTextExtensionConfig | — | Shared extension bundle. |
onContentError | (error) => void | — | Invalid content callback. |
onEditorReady | (editor) => void | — | TipTap editor instance when ready. |
className | string | — | Merged onto the root. |
classNames | Partial<Record<RichTextEditorSlot, string>> | — | Per-slot styling. |
locale / t | i18n overrides | — | Per-instance locale / translator. |
aria-label / aria-labelledby | labeling | — | Required for standalone fields. |
Slots
| Slot | Applied to |
|---|---|
root | Field shell |
toolbar | Attached toolbar |
toolbarGroup | Command groups |
control | Icon command buttons |
blockMenu | Block type dropdown |
linkPopover | Link form popover |
content | TipTap editor surface |
Hook
useRichTextEditor(options) returns the view-model: editor, state, commands, toolbar/link overlay state, rootProps, toolbarProps, contentProps, and keyboard helpers. Spread the prop bags onto named parts; wire your own chrome to commands when you skip RichTextEditorDefaultToolbar.