Rich Text
Safe static rendering for TipTap HTML and ProseMirror JSON documents.
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 (pulls rich-text-internals).
Rich Text is the read surface for documents authored with Rich Text Editor. It renders TipTap HTML or ProseMirror JSON through one sanitize-then-render pipeline — no raw dangerouslySetInnerHTML. Pair it with Code Block when you want highlighted fences via a trusted renderers.codeBlock.
Rich Text renders TipTap HTML or ProseMirror JSON. Use Markdown Renderer when Markdown source is your canonical content format.
When To Use
- Display HTML or JSON produced by Rich Text Editor (or an equivalent TipTap schema) in detail views, table cells, and read-pretty forms.
- You need a closed sanitizer for untrusted HTML before it becomes React nodes.
- Prefer JSON + schema version when the stored document must evolve with custom nodes.
- Use Markdown Renderer instead when the canonical source is Markdown, not TipTap HTML/JSON.
Features
| Area | Behavior |
|---|---|
| Formats | format="html" (default) or format="json" |
| Pipeline | Parse → sanitize (closed schema) → React; JSON goes through TipTap static render first |
| Empty | Semantically empty values render Empty Value Placeholder |
| Fail-closed | Unknown nodes / schema mismatch → fallback + optional onRenderError |
| Code fences | Default <pre><code>; override with trusted renderers.codeBlock |
| Typography | Scoped fui-rich-text styles from rich-text-internals (no Tailwind Typography plugin required) |
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-textFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/rich-textFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/rich-textFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/rich-textregistryDependencies: @f-ui-plus/rich-text-internals, empty-value-placeholder. Runtime: @tiptap/static-renderer, rehype-parse, rehype-react, rehype-sanitize, unified.
Usage
import { RichText } from "@/components/f-ui/rich-text/rich-text";
export function Page() {
return <RichText value="<p>Hello <strong>world</strong></p>" />;
}Examples
HTML
Trusted sample HTML with headings, lists, links, and a blockquote.
Product update
Ship notes with bold, emphasis, and a trusted link.
- Paragraphs and lists
- Headings 1–3
Trusted HTML only — sanitize on the server too.
"use client";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
const SAMPLE_HTML = [
"<h2>Product update</h2>",
"<p>Ship notes with <strong>bold</strong>, <em>emphasis</em>, and a ",
'<a href="https://example.com">trusted link</a>.</p>',
"<ul><li>Paragraphs and lists</li><li>Headings 1–3</li></ul>",
"<blockquote><p>Trusted HTML only — sanitize on the server too.</p></blockquote>",
].join("");
export function RichTextDemo() {
return <RichText value={SAMPLE_HTML} />;
}JSON
Toggle valid ProseMirror JSON, the canonical empty document, and an unknown-node payload. Invalid documents show fallback.
Structured body
Valid ProseMirror JSON with bold marks.
"use client";
import { useState } from "react";
import type { JSONContent } from "@/components/f-ui/rich-text-internals/rich-text-types";
import { EMPTY_RICH_TEXT_JSON } from "@/components/f-ui/rich-text-internals/rich-text-schema";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
import { Button } from "@/components/ui/button";
const VALID_JSON: JSONContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Structured body" }],
},
{
type: "paragraph",
content: [
{ type: "text", text: "Valid ProseMirror JSON with " },
{ type: "text", text: "bold", marks: [{ type: "bold" }] },
{ type: "text", text: " marks." },
],
},
],
};
const UNKNOWN_NODE_JSON: JSONContent = {
type: "doc",
content: [{ type: "unknownNode" }],
};
type Sample = "valid" | "empty" | "unknown";
const SAMPLES: Record<Sample, JSONContent> = {
valid: VALID_JSON,
empty: EMPTY_RICH_TEXT_JSON,
unknown: UNKNOWN_NODE_JSON,
};
export function RichTextJsonDemo() {
const [sample, setSample] = useState<Sample>("valid");
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={sample === "valid" ? "default" : "outline"}
onClick={() => setSample("valid")}
>
Valid JSON
</Button>
<Button
type="button"
variant={sample === "empty" ? "default" : "outline"}
onClick={() => setSample("empty")}
>
Empty document
</Button>
<Button
type="button"
variant={sample === "unknown" ? "default" : "outline"}
onClick={() => setSample("unknown")}
>
Unknown node
</Button>
</div>
<RichText
format="json"
value={SAMPLES[sample]}
fallback={<span>Unavailable — document failed to render</span>}
/>
</div>
);
}Sanitization
Hostile markup with <script>, event attributes, a javascript: link, and id / name. Client filtering is defense in depth — sanitize again on the server before persistence or publication.
Client sanitization strips scripts, event handlers, javascript: links, and id/name. Server sanitization is still required before persistence or publication.
"use client";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
const HOSTILE_HTML = [
'<p id="clobber" name="clobber" onclick="alert(1)">Looks safe</p>',
"<script>document.body.innerHTML='owned'</script>",
'<a href="javascript:alert(1)">javascript link</a>',
"<p>Visible text remains after client sanitization.</p>",
].join("");
export function RichTextSanitizationDemo() {
return (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
Client sanitization strips scripts, event handlers,{" "}
<code>javascript:</code> links, and <code>id</code>/<code>name</code>.
Server sanitization is still required before persistence or publication.
</p>
<RichText value={HOSTILE_HTML} />
</div>
);
}Code Block
Map sanitized fences to Plus Code Block through a trusted renderer.
Trusted code fences can map to Plus Code Block:
"use client";
import { CodeBlock } from "@/components/f-ui/code-block/code-block";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
const SAMPLE_HTML = [
"<p>Trusted code fences can map to Plus Code Block:</p>",
'<pre><code class="language-ts">const greeting = "hello";\nconsole.log(greeting);</code></pre>',
].join("");
export function RichTextCodeBlockDemo() {
return (
<RichText
value={SAMPLE_HTML}
renderers={{
codeBlock: ({ source, language }) => (
<CodeBlock source={source} language={language} />
),
}}
/>
);
}Composition
RichText
└── renderRichText
├── HTML → rehype-parse → sanitize → rehype-react
└── JSON → TipTap static renderer → same sanitize path
└── optional renderers.codeBlock → CodeBlockSecurity
| Threat | Default |
|---|---|
<script> / media / forms / SVG | Removed by the closed sanitizer schema |
| Event-handler attributes | Stripped |
javascript: and exotic link schemes | Blocked; link may remain without a dangerous href |
id / name clobbering | Removed |
| Unknown JSON nodes | Fail closed → fallback |
Custom renderers | Trusted app code; you own the security trade-off |
Client sanitization is not a server security boundary. Validate schema and sanitize HTML on the backend before storage or public rendering.
Edge Cases & Errors
Empty Documents
Semantically empty HTML ("") and EMPTY_RICH_TEXT_JSON render the empty placeholder. Pass emptyText to customize the message.
Invalid JSON / Schema Mismatch
Unknown nodes or an unsupported schemaVersion call onRenderError (when provided) and render fallback instead of throwing into the tree.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | JSONContent | null | undefined | (required) | Document to render. |
format | "html" | "json" | "html" | Value format. |
schemaVersion | number | — | When set, must match RICH_TEXT_SCHEMA_VERSION. |
fallback | ReactNode | null | Shown when rendering fails closed. |
emptyText | ReactNode | — | Passed to Empty Value Placeholder when empty. |
renderers | RichTextRendererMap | — | Trusted overrides (e.g. codeBlock). |
extensionConfig | RichTextExtensionConfig | — | Shared extension bundle for custom schema parity. |
onRenderError | (error: Error) => void | — | Optional logging hook for fail-closed paths. |
className | string | — | Merged onto the root. |
classNames | Partial<Record<RichTextSlot, string>> | — | Per-slot styling. |
Slots
| Slot | Applied to |
|---|---|
root | Outer wrapper |
content | Inner fui-rich-text prose region |