RichTextEditor

free

RichTextEditor combines Tiptap editing with composable formatting tools for notes, comments, and long-form fields.

@tiptap/extension-image@tiptap/react@tiptap/starter-kitreact-icons
Preview
Dark
0 characters
import { useState } from 'react';
import RichTextEditor from '@/components/composites/RichTextEditor';

const initialContent =
  '<h4>Quarterly planning</h4><p>Capture the decisions and next steps here.</p>';

export default function UsageDemo() {
  const [htmlLength, setHtmlLength] = useState(0);

  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <RichTextEditor
        content={initialContent}
        onChange={({ html }) => setHtmlLength(html.length)}
      />
      <div className="flex justify-end">
        <span className="font-mono tabular-nums">{htmlLength} characters</span>
      </div>
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add RichTextEditor

Examples

Custom Toolbar

Preview
Dark
import RichTextEditor from '@/components/composites/RichTextEditor';

export default function CustomToolbarDemo() {
  return (
    <div className="w-full max-w-2xl">
      <RichTextEditor
        content="<p>Use a smaller toolbar for short comments and notes.</p>"
        customToolBar={(editor) => (
          <>
            <RichTextEditor.ToolButtonBold editor={editor} />
            <RichTextEditor.ToolButtonItalic editor={editor} />
            <RichTextEditor.ToolButtonStrike editor={editor} />
            <RichTextEditor.ToolButtonCode editor={editor} />
            <RichTextEditor.ToolButtonBlockquote editor={editor} />
            <RichTextEditor.ToolButtonHeading
              editor={editor}
              headingLevel={[2, 3]}
            />
            <RichTextEditor.ToolButtonBulletList editor={editor} />
            <RichTextEditor.ToolButtonOrderedList editor={editor} />
            <RichTextEditor.ToolButtonCodeBlock editor={editor} />
            <RichTextEditor.ToolButtonHorizontalRule editor={editor} />
            <RichTextEditor.ToolButtonUndo editor={editor} />
            <RichTextEditor.ToolButtonRedo editor={editor} />
          </>
        )}
      />
    </div>
  );
}

Custom Editor

Preview
Dark
import { useState } from 'react';
import Image from '@tiptap/extension-image';
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import RichTextEditor from '@/components/composites/RichTextEditor';

const initialContent =
  '<p>This editor accepts images from the local file picker.</p>';

export default function CustomEditorDemo() {
  const [uploadedFile, setUploadedFile] = useState('No image selected');
  const editor = useEditor({
    extensions: [StarterKit, Image],
    content: initialContent,
    immediatelyRender: false,
    editorProps: {
      attributes: {
        class: 'p-2 min-h-20 focus:outline-hidden',
      },
    },
  });

  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <RichTextEditor
        content={initialContent}
        customEditor={editor}
        customToolBar={(editor) => (
          <>
            <RichTextEditor.ToolButtonBold editor={editor} />
            <RichTextEditor.ToolButtonItalic editor={editor} />
            <RichTextEditor.ToolButtonImage
              editor={editor}
              onUpload={(file) => setUploadedFile(file.name)}
            />
          </>
        )}
      />
    </div>
  );
}

Invalid

Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import RichTextEditor from '@/components/composites/RichTextEditor';

export default function InvalidDemo() {
  const [invalid, setInvalid] = useState(true);

  return (
    <div className="flex w-full max-w-2xl flex-col gap-4">
      <RichTextEditor
        invalid={invalid}
        content="<p>A comment is required before continuing.</p>"
        editorContentClass="min-h-[120px]"
      />
      <Button
        variant="subtle"
        onClick={() => setInvalid((current) => !current)}
      >
        {invalid ? 'Clear invalid state' : 'Mark as invalid'}
      </Button>
    </div>
  );
}

With Form

Preview
Dark
Share progress, blockers, and next steps.
import { useState } from 'react';
import Alert from '@/components/ui/Alert';
import Button from '@/components/ui/Button';
import Form from '@/components/ui/Form';
import RichTextEditor from '@/components/composites/RichTextEditor';

export default function WithFormDemo() {
  const [hasContent, setHasContent] = useState(false);
  const [submitted, setSubmitted] = useState(false);
  const [showSuccess, setShowSuccess] = useState(false);

  const invalid = submitted && !hasContent;

  return (
    <div className="flex flex-col gap-4 w-full">
      {showSuccess ? (
        <Alert
          closable
          showIcon
          variant="success"
          role="status"
          onClose={() => setShowSuccess(false)}
        >
          Update saved.
        </Alert>
      ) : null}
      <Form
        className="w-full max-w-2xl"
        onSubmit={(event) => {
          event.preventDefault();
          setSubmitted(true);
          setShowSuccess(hasContent);
        }}
      >
        <Form.Field
          asterisk
          label="Weekly update"
          hint="Share progress, blockers, and next steps."
          invalid={invalid}
          errorMessage="Write an update before saving."
        >
          <RichTextEditor
            content=""
            invalid={invalid}
            editorContentClass="min-h-[120px]"
            onChange={({ text }) => {
              setHasContent(Boolean(text.trim()));
              setSubmitted(false);
              setShowSuccess(false);
            }}
          />
        </Form.Field>
        <Button type="submit" variant="solid">
          Save update
        </Button>
      </Form>
    </div>
  );
}

API

RichTextEditor is coupled to Tiptap by design. Without customEditor, it creates a StarterKit editor and supplies the default toolbar. With customEditor, the caller owns the editor extensions and its update lifecycle.

PropDescriptionTypeDefault
contentInitial HTML, and the HTML value applied when the prop changes.string''
invalidApplies the destructive editor state.booleanfalse
customToolBarReplaces the default toolbar with a renderer receiving the Tiptap editor and the built-in toolbar component map.RichTextEditorToolbarRendererDefault toolbar
onChangeCalled by the internally created editor after an update.(payload: RichTextEditorChangePayload) => void
editorContentClassAdditional classes applied to the EditorContent element.string
customEditorUses a caller-owned Tiptap editor instance instead of creating one internally.Editor | null
refRef forwarded to the EditorContent root element.Ref<HTMLDivElement>
...restRemaining EditorContent props, excluding editor, ref, and onChange.EditorContentProps

Compound Toolbar Exports

The default export also exposes the reusable toolbar pieces and update hook:

RichTextEditor.ToolButton, RichTextEditor.ToolButtonBold, RichTextEditor.ToolButtonItalic, RichTextEditor.ToolButtonStrike, RichTextEditor.ToolButtonCode, RichTextEditor.ToolButtonBlockquote, RichTextEditor.ToolButtonHeading, RichTextEditor.ToolButtonParagraph, RichTextEditor.ToolButtonBulletList, RichTextEditor.ToolButtonOrderedList, RichTextEditor.ToolButtonCodeBlock, RichTextEditor.ToolButtonHardBreak, RichTextEditor.ToolButtonHorizontalRule, RichTextEditor.ToolButtonImage, RichTextEditor.ToolButtonUndo, RichTextEditor.ToolButtonRedo, and RichTextEditor.useEditorUpdate.

Types

import type { ComponentType, ReactNode } from 'react';
import type { Editor, EditorContentProps, JSONContent } from '@tiptap/react';
 
type RichTextEditorChangePayload = {
  text: string;
  html: string;
  json: JSONContent;
};
 
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
type ToolbarComponents = {
  ToolButtonBold: ComponentType<{ editor: Editor }>;
  ToolButtonItalic: ComponentType<{ editor: Editor }>;
  ToolButtonStrike: ComponentType<{ editor: Editor }>;
  ToolButtonCode: ComponentType<{ editor: Editor }>;
  ToolButtonBlockquote: ComponentType<{ editor: Editor }>;
  ToolButtonHeading: ComponentType<{
    editor: Editor;
    headingLevel?: HeadingLevel[];
  }>;
  ToolButtonBulletList: ComponentType<{ editor: Editor }>;
  ToolButtonOrderedList: ComponentType<{ editor: Editor }>;
  ToolButtonCodeBlock: ComponentType<{ editor: Editor }>;
  ToolButtonHorizontalRule: ComponentType<{ editor: Editor }>;
  ToolButtonParagraph: ComponentType<{ editor: Editor }>;
  ToolButtonUndo: ComponentType<{ editor: Editor }>;
  ToolButtonRedo: ComponentType<{ editor: Editor }>;
};
 
type RichTextEditorToolbarRenderer = (
  editor: Editor,
  components: ToolbarComponents,
) => ReactNode;