RichTextEditor
FormSchema-based rich text with formatting, lists, links, undo, and JSON output.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import type { JSONContent } from "@tiptap/core";
import { useState } from "react";
const initial: JSONContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "A little more considered." }],
},
{
type: "paragraph",
content: [
{ type: "text", text: "Give your next release a clear direction. " },
{ type: "text", text: "Select this text", marks: [{ type: "bold" }] },
{ type: "text", text: " and try the formatting controls." },
],
},
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Start with a focused scope" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Make the details feel right" }],
},
],
},
],
},
],
};
export default function RichTextEditorExample() {
const [document, setDocument] = useState(initial);
return (
<div className="w-full max-w-2xl space-y-7">
<RichTextEditor
label="Release brief"
value={document}
onValueChange={setDocument}
/>
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-xs font-medium">
Live document JSON
</summary>
<pre className="mt-3 max-h-48 overflow-auto text-xs">
{JSON.stringify(document, null, 2)}
</pre>
</details>
<RichTextEditor
label="Published summary"
readOnly
defaultValue={{
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "A read-only document keeps the same typography and semantic structure.",
},
],
},
],
}}
/>
<RichTextEditor label="Quick note" compact />
</div>
);
}Usage
Use Tiptap 3.32.0 with the matching react, core, pm, and starter-kit packages already installed in both apps. Own JSONContent with value/onValueChange, or supply defaultValue for local editing. The toolbar supports inline marks, lists, blockquotes, headings, links, and history; standard keyboard shortcuts also work. compact hides heading/link controls. Persist validated JSON and use a schema-aware renderer; do not insert arbitrary HTML into another page. Client editing does not replace server authorization or content validation.
import { RichTextEditor } from "@/components/ui/rich-text-editor";Shipped as editable source in src/components/ui/rich-text-editor.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/rich-text-editor.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { useEditor, EditorContent, useEditorState } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import styles from "./rich-text-editor.module.css";
import {
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Quote,
Undo2,
Redo2,
Link2,
Unlink,
} from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface RichTextEditorProps {
label: string;
value?: JSONContent;
defaultValue?: JSONContent;
onValueChange?: (document: JSONContent) => void;
readOnly?: boolean;
compact?: boolean;
className?: string;
}
export function safeEditorLink(value: string) {
try {
const url = new URL(value);
return ["https:", "http:", "mailto:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
}
export function RichTextEditor({
label,
value,
defaultValue,
onValueChange,
readOnly = false,
compact = false,
className,
}: RichTextEditorProps) {
const id = useId();
const [linkOpen, setLinkOpen] = useState(false);
const [href, setHref] = useState("");
const [error, setError] = useState("");
const editor = useEditor({
immediatelyRender: false,
shouldRerenderOnTransaction: false,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
link: {
openOnClick: false,
autolink: false,
protocols: ["http", "https", "mailto"],
},
}),
],
content: value ??
defaultValue ?? { type: "doc", content: [{ type: "paragraph" }] },
editable: !readOnly,
editorProps: {
attributes: {
role: "textbox",
"aria-label": label,
"aria-multiline": "true",
"aria-readonly": String(readOnly),
class: `min-h-40 p-4 text-sm leading-7 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${styles.content}`,
},
},
onUpdate: ({ editor: current }) => onValueChange?.(current.getJSON()),
});
const state = useEditorState({
editor,
selector: ({ editor: current }) =>
current
? {
bold: current.isActive("bold"),
italic: current.isActive("italic"),
underline: current.isActive("underline"),
strike: current.isActive("strike"),
bullet: current.isActive("bulletList"),
ordered: current.isActive("orderedList"),
quote: current.isActive("blockquote"),
heading: current.isActive("heading", { level: 2 }),
undo: current.can().undo(),
redo: current.can().redo(),
link: current.isActive("link"),
characters: current.getText().length,
}
: null,
});
useEffect(() => {
if (editor) editor.setEditable(!readOnly);
}, [editor, readOnly]);
useEffect(() => {
if (
editor &&
value &&
JSON.stringify(editor.getJSON()) !== JSON.stringify(value)
)
editor.commands.setContent(value, { emitUpdate: false });
}, [editor, value]);
const applyLink = () => {
const safe = safeEditorLink(href);
if (!safe) {
setError("Enter an absolute http, https, or mailto URL.");
return;
}
editor
?.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: safe })
.run();
setLinkOpen(false);
};
const tools =
editor && state
? [
{
label: "Bold",
icon: Bold,
active: state.bold,
run: () => editor.chain().focus().toggleBold().run(),
},
{
label: "Italic",
icon: Italic,
active: state.italic,
run: () => editor.chain().focus().toggleItalic().run(),
},
{
label: "Underline",
icon: Underline,
active: state.underline,
run: () => editor.chain().focus().toggleUnderline().run(),
},
{
label: "Strikethrough",
icon: Strikethrough,
active: state.strike,
run: () => editor.chain().focus().toggleStrike().run(),
},
{
label: "Bullet list",
icon: List,
active: state.bullet,
run: () => editor.chain().focus().toggleBulletList().run(),
},
{
label: "Numbered list",
icon: ListOrdered,
active: state.ordered,
run: () => editor.chain().focus().toggleOrderedList().run(),
},
{
label: "Blockquote",
icon: Quote,
active: state.quote,
run: () => editor.chain().focus().toggleBlockquote().run(),
},
]
: [];
return (
<div className={cn("min-w-0 space-y-2", className)}>
<p className="text-sm font-medium">{label}</p>
<div className="overflow-hidden rounded-xl border bg-background">
{!readOnly && (
<div
role="group"
aria-label={`${label} formatting`}
className="flex flex-wrap gap-1 border-b bg-muted/20 p-2"
>
{tools.map((tool) => (
<Button
key={tool.label}
type="button"
size="icon-sm"
variant={tool.active ? "secondary" : "ghost"}
aria-label={tool.label}
aria-pressed={tool.active}
onClick={tool.run}
>
<tool.icon className="size-4" />
</Button>
))}
{!compact && (
<>
<Button
type="button"
size="sm"
variant={state?.heading ? "secondary" : "ghost"}
aria-pressed={state?.heading ?? false}
disabled={!editor}
onClick={() =>
editor?.chain().focus().toggleHeading({ level: 2 }).run()
}
>
Heading
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Insert link"
aria-expanded={linkOpen}
disabled={!editor}
onClick={() => {
setHref(editor?.getAttributes("link").href ?? "");
setError("");
setLinkOpen(!linkOpen);
}}
>
<Link2 className="size-4" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Remove link"
disabled={!state?.link}
onClick={() =>
editor
?.chain()
.focus()
.extendMarkRange("link")
.unsetLink()
.run()
}
>
<Unlink className="size-4" />
</Button>
</>
)}
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Undo edit"
disabled={!state?.undo}
onClick={() => editor?.chain().focus().undo().run()}
>
<Undo2 className="size-4" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Redo edit"
disabled={!state?.redo}
onClick={() => editor?.chain().focus().redo().run()}
>
<Redo2 className="size-4" />
</Button>
</div>
)}
{linkOpen && !readOnly && (
<div className="space-y-2 border-b p-3">
<Label htmlFor={id}>Link address</Label>
<div className="flex gap-2">
<Input
id={id}
value={href}
onChange={(event) => setHref(event.target.value)}
placeholder="https://example.com"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyLink();
}
}}
/>
<Button type="button" size="sm" onClick={applyLink}>
Apply link
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setLinkOpen(false);
editor?.commands.focus();
}}
>
Cancel
</Button>
</div>
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
)}
<EditorContent editor={editor} />
{!editor && (
<p className="p-4 text-sm text-muted-foreground" role="status">
Loading editor…
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{state?.characters ?? 0} characters ·{" "}
{readOnly
? "Read-only document"
: "Supports keyboard formatting and undo"}
</p>
</div>
);
}Required styles: src/components/ui/rich-text-editor.module.css
.content p {
margin-block: 0.5rem;
}
.content h2,
.content h3 {
margin-block: 0.75rem;
font-weight: 600;
}
.content h2 {
font-size: 1.25rem;
}
.content h3 {
font-size: 1.125rem;
}
.content ul {
list-style: disc;
padding-inline-start: 1.5rem;
}
.content ol {
list-style: decimal;
padding-inline-start: 1.5rem;
}
.content blockquote {
border-inline-start: 2px solid var(--border);
padding-inline-start: 1rem;
color: var(--muted-foreground);
}
.content a {
color: var(--primary);
text-decoration: underline;
}
.content pre {
overflow-x: auto;
border-radius: 0.375rem;
background: var(--muted);
padding: 0.75rem;
}
.content code {
border-radius: 0.25rem;
background: var(--muted);
padding-inline: 0.25rem;
}Editor dependencies
Already installed in both apps. Use matching versions when copying the component into another project.
npm install @tiptap/react@3.32.0 @tiptap/core@3.32.0 @tiptap/pm@3.32.0 @tiptap/starter-kit@3.32.0API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible editor name and toolbar group name. |
| value / defaultValue | JSONContentDefault: empty document | Controlled or initial Tiptap document. External value changes replace the document without echoing an update callback. |
| onValueChange | (document: JSONContent) => voidDefault: — | Receive schema-based document JSON after edits. |
| readOnly | booleanDefault: false | Disable editing and hide the toolbar. |
| compact | booleanDefault: false | Hide heading and link controls. |
| className | stringDefault: — | Extend the container with semantic theme classes. |