mui-tiptap: A customizable Material UI styled WYSIWYG rich text editor, using Tiptap.
- ✨ Styled based on your own MUI theme (colors, fonts, light vs dark mode, etc.)
- 🛠️ Built on powerful Tiptap and ProseMirror foundations (extensible, real-time collaborative editing, cross-platform support, etc.)
Features:
- 🧰 An all-in-one
RichTextEditor
component to get started immediately (no other components or hooks needed!), or individual modular components to customize to your needs - 😎 Built-in styles for Tiptap’s extensions (text formatting, lists, tables, Google Docs-like collaboration cursors; you name it!)
▶️ Composable and extendable menu buttons and controls for the standard Tiptap extensions- 🖼️
ResizableImage
extension for adding and resizing images directly in the editor - ⚓
HeadingWithAnchor
extension for dynamic GitHub-like anchor links for every heading you add - 🔗
LinkBubbleMenu
so adding and editing links is a breeze - 🔢
FontSize
extension for controlling text sizes - 🔳
TableImproved
extension that fixes problems in the underlying TiptapTable
extension - 📝
TableBubbleMenu
for interactively editing your rich text tables - 🏗️ General-purpose
ControlledBubbleMenu
for building your own custom menus, solving some shortcomings of the TiptapBubbleMenu
- And more!
README Table of Contents
Try it yourself in this CodeSandbox live demo!
npm install mui-tiptap
or
yarn add mui-tiptap
There are peer dependencies on @mui/material
and @mui/icons-material
(and their @emotion/
peers), react-icons
, and @tiptap/
packages. These should be installed automatically by default if you’re using npm 7+ or pnpm. Otherwise, if your project doesn’t already use those, you can install them with:
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled react-icons @tiptap/react @tiptap/extension-heading @tiptap/extension-image @tiptap/extension-table @tiptap/pm @tiptap/core
or
yarn add @mui/material @mui/icons-material @emotion/react @emotion/styled react-icons @tiptap/react @tiptap/extension-heading @tiptap/extension-image @tiptap/extension-table @tiptap/pm @tiptap/core
The simplest way to render a rich text editor is to use the RichTextEditor
component:
import { Button } from "@mui/material";
import StarterKit from "@tiptap/starter-kit";
import { RichTextEditor, type RichTextEditorRef } from "mui-tiptap";
import { useRef } from "react";
function App() {
const rteRef = useRef<RichTextEditorRef>(null);
return (
<div>
<RichTextEditor
ref={rteRef}
extensions={[StarterKit]} // Or any Tiptap extensions you wish!
content="<p>Hello world</p>" // Initial content for the editor
// Optionally include `renderControls` for a menu-bar atop the editor:
renderControls={() => (
<MenuControlsContainer>
<MenuSelectHeading />
<MenuDivider />
<MenuButtonBold />
<MenuButtonItalic />
{/* Add more controls of your choosing here */}
</MenuControlsContainer>
)}
/>
<Button onClick={() => console.log(rteRef.current?.editor?.getHTML())}>
Log HTML
</Button>
</div>
);
}
Check out mui-tiptap extensions and components below to learn about extra Tiptap extensions and components (like more to include in renderControls
) that you can use. See src/demo/Editor.tsx
for a more thorough example of using RichTextEditor
.
If you need more customization, you can instead define your editor using Tiptap’s useEditor
hook, and lay out your UI using a selection of mui-tiptap
components (and/or your own components).
Pass the editor
to mui-tiptap
’s RichTextEditorProvider
component at the top of your component tree. From there, render whatever children within the provider that fit your needs.
The easiest is option is the RichTextField
component, which is what RichTextEditor
uses under the hood:
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
MenuButtonBold,
MenuButtonItalic,
MenuControlsContainer,
MenuDivider,
MenuSelectHeading,
RichTextEditorProvider,
RichTextField,
} from "mui-tiptap";
function App() {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello <b>world</b>!</p>",
});
return (
<RichTextEditorProvider editor={editor}>
<RichTextField
controls={
<MenuControlsContainer>
<MenuSelectHeading />
<MenuDivider />
<MenuButtonBold />
<MenuButtonItalic />
{/* Add more controls of your choosing here */}
</MenuControlsContainer>
}
/>
</RichTextEditorProvider>
);
}
Or if you want full control over the UI, instead of RichTextField
, you can build the editor area yourself and then just use the <RichTextContent />
component where you want the (styled) editable rich text content to appear. RichTextContent
is the MUI-themed version of Tiptap's EditorContent
component.
Use the RichTextReadOnly
component and just pass in your HTML or ProseMirror JSON and your configured Tiptap extensions, like:
<RichTextReadOnly content="<p>Hello world</p>" extensions={[StarterKit]} />
Alternatively, you can set the RichTextEditor
editable
prop (or useEditor
editable
option) to false
for a more configurable read-only option. Use RichTextReadOnly
when:
- You just want to efficiently render editor HTML/JSON content directly, without any outlined field styling, controls setup, extra listener logic, access to the
editor
object, etc. (This component also skips creating the Tiptapeditor
ifcontent
is empty, which can help performance.) - You want a convenient way to render content that updates as the
content
prop changes. (RichTextEditor
by contrast does not re-render automatically oncontent
changes, as described below.)
A modified version of Tiptap’s Heading
extension, with dynamic GitHub-like anchor links for every heading you add. An anchor link button will appear to the left of a heading when hovering over it, when the editor
has editable
set to false
. This allows users to share links and jump to specific headings within your rendered editor content.
Sets text font size. This extension requires the @tiptap/extension-text-style
package to be installed and its TextStyle
mark to be included in your extensions
.
Can be controlled with the MenuSelectFontSize
component.
setFontSize()
: Set the text font size (using a valid CSSfont-size
property). ex:"12px"
,"2em"
,"small"
unsetFontSize()
: Remove any previously set font size, reverting to the default size for the given mark.
To be used in conjunction with the LinkBubbleMenu
component, as this extension provides editor commands to control the state of the link bubble menu.
openLinkBubbleMenu()
: Open/show the link bubble menu. Create a link if one doesn't exist at the current cursor selection, or edit the existing link if there is already a link at the current selection.editLinkInBubbleMenu()
: Edit an existing link in the bubble menu, to be used when currently viewing a link in the already-opened bubble menu.closeLinkBubbleMenu()
: Close/hide the link bubble menu, canceling any ongoing edits.
A modified version of Tiptap’s Image
extension, which adds the ability to resize images directly in the editor. A drag handle appears in the bottom right when clicking on an image, so users can interactively change the size.
A modified version of Tiptap’s Table
extension that fixes problems related to column-resizing and editable state.
Namely, this version of the extension, coupled with the mui-tiptap
CSS styles ensures that:
- Columns respect their resized widths even when the editor has
editable=false
- Column resizing is possible regardless of initial editor state, when toggling from
editable=false
toeditable=true
(Resolves these reported Tiptap issues: 1, 2, 3.)
Component | Description |
---|---|
RichTextEditor |
An all-in-one component to directly render a MUI-styled Tiptap rich text editor field. Utilizes many of the below components internally. See the "Get started" notes on usage above. In brief: <RichTextEditor ref={rteRef} content="<p>Hello world</p>" extensions={[...]} /> |
RichTextReadOnly |
An all-in-one component to directly render read-only Tiptap editor content. While RichTextEditor (or useEditor , RichTextEditorProvider , and RichTextContent ) can be used as read-only via the editor's editable prop, this is a simpler and more efficient version that only renders content and nothing more (e.g., does not instantiate a toolbar, bubble menu, etc. that you probably wouldn’t want in a read-only context, and it skips instantiating the editor at all if there's no content to display). |
RichTextEditorProvider |
Uses React context to make the Tiptap editor available to any nested components so that the editor does not need to be manually passed in at every level. Required as a parent for most mui-tiptap components besides the all-in-one RichTextEditor and RichTextReadOnly . Utilize the provided editor in your own components via the useRichTextEditorContext() hook. |
RichTextField |
Renders the Tiptap rich text editor content and a controls menu bar. With the "outlined" variant, renders a bordered UI similar to the Material UI TextField . The "standard" variant does not have an outline/border. |
MenuBar |
A collapsible, optionally-sticky container for showing editor controls atop the editor content. (This component is used to contain RichTextEditor ’s renderControls and RichTextField ’s controls , but can be used directly if you’re doing something more custom.) |
RichTextContent |
Renders a Material UI styled version of Tiptap rich text editor content. Applies all CSS rules for formatting, as a styled alternative to Tiptap’s <EditorContent /> component. (Used automatically within RichTextEditor and RichTextField .) |
LinkBubbleMenu |
Renders a bubble menu when viewing, creating, or editing a link. Requires the Tiptap Link extension (@tiptap/extension-link ) and the mui-tiptap LinkBubbleMenuHandler extension. Pairs well with the <MenuButtonEditLink /> component. If you're using RichTextEditor , include this component via RichTextEditor ’s children render-prop. Otherwise, include the LinkBubbleMenu as a child of the component where you call useEditor and render your RichTextField or RichTextContent . (The bubble menu itself will be positioned appropriately as long as it is re-rendered whenever the Tiptap editor forces an update, which will happen if it's a child of the component using useEditor ). See src/demo/Editor.tsx for an example of this. |
TableBubbleMenu |
Renders a bubble menu to manipulate the contents of a Table (add or delete columns or rows, merge cells, etc.), when the user's caret/selection is inside a Table. For use with mui-tiptap’s TableImproved extension or Tiptap’s @tiptap/extension-table extension. If you're using RichTextEditor , include this component via RichTextEditor ’s children render-prop. Otherwise, include the TableBubbleMenu as a child of the component where you call useEditor and render your RichTextField or RichTextContent . (The bubble menu itself will be positioned appropriately as long as it is re-rendered whenever the Tiptap editor forces an update, which will happen if it's a child of the component using useEditor ). See src/demo/Editor.tsx for an example of this. |
ControlledBubbleMenu |
General-purpose component for building your own custom bubble menus, solving some shortcomings of Tiptap’s BubbleMenu . This is what both LinkBubbleMenu and TableBubbleMenu use under the hood. |
These controls components to help you quickly put together your menu bar, for each of the various Tiptap extensions you may want to use.
You can override all props for these components (e.g. to change the icon, tooltip label, shortcut keys shown, onClick
behavior, etc.). Or easily create controls for your own extensions and use-cases with the base MenuButton
and MenuSelect
components.
Extension | mui-tiptap component(s) |
---|---|
@tiptap/extension-blockquote |
MenuButtonBlockquote |
@tiptap/extension-bold |
MenuButtonBold |
@tiptap/extension-bullet-list |
MenuButtonBulletedList |
@tiptap/extension-code |
MenuButtonCode |
@tiptap/extension-code-block |
MenuButtonCodeBlock |
@tiptap/extension-font-family |
MenuSelectFontFamily (use the options prop to specify which font families can be selected, like [{ label: "Monospace", value: "monospace" }, ...] ) |
mui-tiptap’s FontSize |
MenuSelectFontSize (use the options prop to override the default size options) |
mui-tiptap’s HeadingWithAnchor or @tiptap/extension-heading |
MenuSelectHeading |
@tiptap/extension-history |
MenuButtonRedo , MenuButtonUndo |
@tiptap/extension-horizontal-rule |
MenuButtonHorizontalRule |
mui-tiptap’s ResizableImage or @tiptap/extension-image |
MenuButtonAddImage |
@tiptap/extension-italic |
MenuButtonItalic |
@tiptap/extension-link |
MenuButtonEditLink (requires the mui-tiptap LinkBubbleMenuHandler extension and <LinkBubbleMenu /> component) |
@tiptap/extension-list-item |
MenuButtonIndent , MenuButtonUnindent |
@tiptap/extension-ordered-list |
MenuButtonOrderedList |
@tiptap/extension-paragraph |
MenuSelectHeading |
@tiptap/extension-strike |
MenuButtonStrikethrough |
@tiptap/extension-subscript |
MenuButtonSubscript |
@tiptap/extension-superscript |
MenuButtonSuperscript |
mui-tiptap’s TableImproved or @tiptap/extension-table |
• Insert new table: MenuButtonAddTable • Edit a table (add columns, merge cells, etc.): TableBubbleMenu , or TableMenuControls if you need an alternative UI to the bubble menu |
@tiptap/extension-task-list |
MenuButtonTaskList |
@tiptap/extension-text-align |
MenuSelectTextAlign (all-in-one select)or MenuButtonAlignLeft , MenuButtonAlignCenter , MenuButtonAlignRight , MenuButtonAlignJustify (individual buttons) |
@tiptap/extension-underline |
MenuButtonUnderline |
Other controls components:
MenuButtonRemoveFormatting
: a control button that removes all inline formatting of marks (calling Tiptap’sunsetAllMarks()
)MenuDivider
: renders a vertical line divider to separate different sections of your menu bar and implicitly group separate controls.MenuControlsContainer
: provides consistent spacing between different editor controls components provided aschildren
.
Typically you will define your controls (for RichTextEditor
’s renderControls
or RichTextField
’s controls
) like:
<MenuControlsContainer>
<MenuSelectHeading />
<MenuDivider />
<MenuButtonBold />
<MenuButtonItalic />
{/* Add more controls of your choosing here */}
</MenuControlsContainer>
All of the menu buttons, select components, and bubble menus allow you to override their default labels and content via props. Examples below.
Buttons
<MenuButtonBold tooltipLabel="Toggle bold" />
Selects
<MenuSelectFontFamily
options={[
{ label: "Monospace", value: "monospace" },
{ label: "Serif", value: "serif" },
]}
aria-label="Font families"
emptyLabel="Font family"
tooltipTitle="Change font family"
unsetOptionLabel="Reset"
/>
<MenuSelectFontSize
aria-label="Font sizes"
tooltipTitle="Change font size"
unsetOptionLabel="Reset"
/>
<MenuSelectHeading
aria-label="Heading types"
tooltipTitle="Change heading type"
labels={{
empty: "Change to…",
paragraph: "Normal text",
heading1: "H1",
heading2: "H2",
heading3: "H3",
heading4: "H4",
heading5: "H5",
heading6: "H6",
}}
/>
<MenuSelectTextAlign
aria-label="Text alignments"
tooltipTitle="Change text alignment"
options={[
{
value: "left",
label: "Text-align left",
shortcutKeys: ["mod", "Shift", "L"],
IconComponent: MyCustomLeftAlignIcon,
},
{
value: "right",
label: "Text-align right",
shortcutKeys: ["mod", "Shift", "R"],
IconComponent: MyCustomRightAlignIcon,
},
]}
/>
Bubble menus
<LinkBubbleMenu
labels={{
viewLinkEditButtonLabel: "Edit link",
viewLinkRemoveButtonLabel: "Remove link",
editLinkAddTitle: "Add new link",
editLinkEditTitle: "Update this link",
editLinkCancelButtonLabel: "Cancel changes",
editLinkTextInputLabel: "Text content",
editLinkHrefInputLabel: "URL",
editLinkSaveButtonLabel: "Save changes",
}}
/>
<TableBubbleMenu
labels={{
insertColumnBefore: "Add a new column before this",
insertColumnAfter: "Add a new column after this",
deleteColumn: "Remove current column",
// And several more. Check props type definition for details!
}}
/>
Browse the official Tiptap extensions, and check out mui-tiptap
’s additional extensions. The easiest way to get started is to install and use Tiptap’s StarterKit
extension, which bundles several common Tiptap extensions.
Extensions that need to be higher precedence (for their keyboard shortcuts, etc.) should come later in your extensions array. (See Tiptap's general notes on extension plugin precedence and ordering here.) For example:
- Put the
TableImproved
(orTable
) extension first in the array.- As noted in the underlying
prosemirror-tables
package, the table editing plugin should have the lowest precedence, since it depends on key and mouse events but other plugins will likely need to take handle those first. For instance, if you want to indent or dedent a list item inside a table, you should be able to do that by pressing tab, and tab should only move between table cells if not within such a nested node.
- As noted in the underlying
- Put the
Blockquote
extension after theBold
extension, soBlockquote
’s keyboard shortcut takes precedence. - Put the
Mention
extension after list-related extensions (TaskList
,TaskItem
,BulletList
,OrderedList
,ListItem
, etc.) so that pressing "Enter" on a mention suggestion will select it, rather than create a new list item (when trying to @mention something within an existing list item).
-
If you’d like
Subscript
andSuperscript
extensions to be mutually exclusive, so that text can't be both superscript and subscript simultaneously, use theexcludes
configuration parameter to exclude each other.-
As described in this Tiptap issue. For instance:
const CustomSubscript = Subscript.extend({ excludes: "superscript", }); const CustomSuperscript = Superscript.extend({ excludes: "subscript", });
-
-
If you’d prefer to be able to style your inline
Code
marks (e.g., make them bold, add links, change font size), you should extend the extension and override theexcludes
field, since by default it uses"_"
to make it mutually exclusive from all other marks. For instance, to allow you to applyCode
with any other inline mark, useexcludes: ""
, or to make it work with all except italics, use:Code.extend({ excludes: "italic" });
By default, RichTextEditor
uses content
the same way that Tiptap’s useEditor
does: it sets the initial content for the editor, and subsequent changes to the content
variable will not change what content is rendered. (Only the user’s editor interaction will.) This can avoid annoyances like overwriting the content while a user is actively typing or editing.
It is not efficient to use RichTextEditor
/useEditor
as a fully “controlled” component where you change content
on each call to the editor’s onUpdate
, due to the fact that editor content must be serialized to get the HTML string (getHTML()
) or ProseMirror JSON (getJSON()
) (see Tiptap docs and this discussion).
But if you need this behavior in certain situations, like you have changed the content
external to the component and separate from the user’s editor interaction, you can call editor.commands.setContent(content)
(docs) within a hook to update the editor document.
For instance, you could use something like the following, which (1) only calls setContent
when the editor is either read-only or unfocused (aiming to avoid losing any in-progress changes the user is making, though keep in mind that changes to isFocused
itself do not cause re-rendering and so won't re-run the effect), and (2) tries to preserve the user’s current selection/caret:
const editor = rteRef.current?.editor;
useEffect(() => {
if (!editor || editor.isDestroyed) {
return;
}
if (!editor.isFocused || !editor.isEditable) {
// Use queueMicrotask per https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
queueMicrotask(() => {
const currentSelection = editor.state.selection;
editor
.chain()
.setContent(content)
.setTextSelection(currentSelection)
.run();
});
}
}, [content, editor, editor?.isEditable, editor?.isFocused]);
You could also alternatively pass content
as an editor dependency via <RichTextEditor … editorDependencies={[content]} />
(or equivalently include it in your useEditor
dependency array), and this will force-recreate the entire editor upon changes to the value. This is a much less efficient option, and it can cause a visual “flash” as the editor is rebuilt.
Note that if these content updates are coming from changes other users are making (e.g. saved to a database), it may be better to use collaborative editing functionality with Yjs, and not rely on content
at all.
Get started here.