Skip to content

RichTextEditor

Form

Schema-based rich text with formatting, lists, links, undo, and JSON output.

Controlled release briefLive JSON documentRead-only summaryCompact note
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.0

API 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.

RichTextEditor props
PropType / DefaultDescription
labelstring

Default: required

Accessible editor name and toolbar group name.
value / defaultValueJSONContent

Default: empty document

Controlled or initial Tiptap document. External value changes replace the document without echoing an update callback.
onValueChange(document: JSONContent) => void

Default: —

Receive schema-based document JSON after edits.
readOnlyboolean

Default: false

Disable editing and hide the toolbar.
compactboolean

Default: false

Hide heading and link controls.
classNamestring

Default: —

Extend the container with semantic theme classes.

Build with these too