f-ui
Components

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.richText so 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

AreaBehavior
Formatsformat="html" (default) or format="json"
OwnershipControlled value / onValueChange or uncontrolled defaultValue
ToolbarAttached ARIA toolbar with block menu, marks, lists, link popover, undo/redo
EmptinessSemantically empty → empty string (HTML) or EMPTY_RICH_TEXT_JSON (JSON)
Change detailsonValueChange(value, details) includes html, json, text, isEmpty, schemaVersion
i18nen / zh-CN via useRichTextEditorI18n under I18nProvider
HeadlessuseRichTextEditor + named parts for custom chrome

Interactions

EventBehavior
Type / pasteUpdates the controlled value; IME composition is queued until confirm
Toolbar commandApplies mark/block while preserving selection (mousedown preventDefault)
Link popoverValidates URL protocols before applying a link mark
BlurDefault Formily validation trigger for kind="richText"
Alt+F10Moves 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-editor
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/rich-text-editor
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/rich-text-editor
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/rich-text-editor

registryDependencies: @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: 1store 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.

Required — empty paragraphs do not count as 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.

Optional plain-text teaser for lists and search.

Required — empty paragraphs do not count as 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 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
    └── RichTextEditorContent

Keyboard

ShortcutBehavior
Alt+F10Focus the toolbar (active or first enabled control)
Arrow Left / RightRoving focus across enabled toolbar controls
Home / EndFirst / last enabled toolbar control
EscapeFrom toolbar or overlay → return focus to the editor
Mod-B / Mod-I / Mod-UBold / italic / underline (platform modifier)
Tab in listsIndent / 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

ThreatDefault
Bypass the UIAttackers can POST arbitrary HTML/JSON — validate and sanitize on the server
Editor HTML outputSchema-constrained, not “server-sanitized”; full sanitize is not run on every keystroke
Unsafe link schemesProtocol checks reduce accidents; backends must still enforce allowlists
Custom extensionsTrusted 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

PropTypeDefaultDescription
format"html" | "json""html"Value format.
valuestring | JSONContentControlled value.
defaultValuestring | JSONContentUncontrolled initial value.
onValueChange(value, details) => voidFires on document updates.
placeholderstringEmpty editor hint.
disabledbooleanfalseDisables editing and toolbar.
readOnlybooleanfalseHides toolbar; content is read-only.
toolbarbooleantrueSet false to omit the default toolbar.
minHeightnumber | stringMinimum height of the content surface.
autoFocusbooleanfalseFocus the editor on mount.
contentPolicy"reject" | "normalize"How invalid incoming content is handled.
extensionConfigRichTextExtensionConfigShared extension bundle.
onContentError(error) => voidInvalid content callback.
onEditorReady(editor) => voidTipTap editor instance when ready.
classNamestringMerged onto the root.
classNamesPartial<Record<RichTextEditorSlot, string>>Per-slot styling.
locale / ti18n overridesPer-instance locale / translator.
aria-label / aria-labelledbylabelingRequired for standalone fields.

Slots

SlotApplied to
rootField shell
toolbarAttached toolbar
toolbarGroupCommand groups
controlIcon command buttons
blockMenuBlock type dropdown
linkPopoverLink form popover
contentTipTap 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.

On this page