'use client'
import TextAlign from '@tiptap/extension-text-align'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Control, RichTextEditor } from '#components/ui/rich-text-editor'
export const RichTextEditorBasic = () => {
const editor = useEditor({
extensions: [
StarterKit.configure({ link: { openOnClick: false } }),
TextAlign.configure({ types: ['paragraph', 'heading'] }),
],
content: `
<h1>Welcome to Saas UI + Tiptap!</h1>
<p>Edit this document using the toolbar above.</p>
`,
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor} maxW="3xl">
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<Control.TextStyle />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
<Control.Underline />
<Control.Strikethrough />
<Control.Code />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.H1 />
<Control.H2 />
<Control.H3 />
<Control.H4 />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.AlignLeft />
<Control.AlignCenter />
<Control.AlignRight />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.Undo />
<Control.Redo />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}
Usage
The Rich Text Editor is built on Tiptap, a headless editor on top of ProseMirror. Install the component with the CLI, then add Tiptap and the extensions you want to use.
pnpm dlx @saas-ui/cli@rc add rich-text-editorpnpm add @tiptap/react @tiptap/starter-kitimport { Control, RichTextEditor } from '#components/ui/rich-text-editor'Create the editor with Tiptap's useEditor hook and pass it to
RichTextEditor.Root. Everything below the root reads the editor from context,
so the toolbar controls need no props.
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
function Editor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello</p>',
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor}>
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}shouldRerenderOnTransaction so the controls follow the selection, and
immediatelyRender: false to avoid a hydration mismatch in server rendered
apps.Anatomy
<RichTextEditor.Root>
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup />
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
<RichTextEditor.Footer />
</RichTextEditor.Root>ControlGroup groups related controls, and the toolbar draws a separator
between the groups.
Controls
Control is a namespace of ready-made controls. Each one maps to a Tiptap
command and reflects the current selection, so Control.Bold renders as active
while the cursor sits inside bold text.
| Control | Requires |
|---|---|
Bold Italic Underline Strikethrough Code | @tiptap/starter-kit |
H1 H2 H3 H4 TextStyle BulletList OrderedList Blockquote Hr | @tiptap/starter-kit |
Link Unlink | @tiptap/starter-kit |
Undo Redo | @tiptap/starter-kit |
AlignLeft AlignCenter AlignRight AlignJustify | @tiptap/extension-text-align |
Highlight | @tiptap/extension-highlight |
A control only works when its extension is registered on the editor. The base
set comes from StarterKit, the rest are opt-in.
Custom controls
Use the control factories to add your own. They take care of the tooltip, the icon button and reading the editor from context.
import { LuListChecks } from 'react-icons/lu'
import { createBooleanControl } from '#components/ui/rich-text-editor'
const ToggleTaskList = createBooleanControl({
label: 'Toggle Task List',
icon: LuListChecks,
command: (editor) => editor.chain().focus().toggleTaskList().run(),
getVariant: (editor) => (editor.isActive('taskList') ? 'subtle' : 'ghost'),
})createSelectControl builds a dropdown for mutually exclusive states like the
block type, and createSwatchControl builds a color picker popover. For a
one-off button, render Control.ButtonControl directly and read the editor with
useRichTextEditorContext.
Examples
Controlled
Pass onUpdate to keep the document in React state. editor.getHTML() returns
the serialized document, editor.getJSON() returns the ProseMirror node.
'use client'
import { useState } from 'react'
import { Box, Stack } from '@chakra-ui/react'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Control, RichTextEditor } from '#components/ui/rich-text-editor'
export const RichTextEditorControlled = () => {
const [content, setContent] = useState<string>('<p>Edit here...</p>')
const editor = useEditor({
shouldRerenderOnTransaction: true,
immediatelyRender: false,
extensions: [StarterKit.configure({ link: { openOnClick: false } })],
content,
onUpdate({ editor }) {
setContent(editor.getHTML())
},
})
if (!editor) return null
return (
<Stack maxW="3xl">
<RichTextEditor.Root editor={editor} maxHeight="2xl">
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
<Control.Underline />
<Control.Strikethrough />
<Control.Code />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
<Box p="4" bg="bg.muted" flex="1" rounded="l2">
<Box
as="pre"
textStyle="sm"
wordWrap="break-word"
whiteSpace="pre-wrap"
>
{content}
</Box>
</Box>
</Stack>
)
}
Placeholder
Add the Placeholder extension from @tiptap/extensions to show a hint while
the document is empty.
'use client'
import { Placeholder } from '@tiptap/extensions'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Control, RichTextEditor } from '#components/ui/rich-text-editor'
export const RichTextEditorWithPlaceholder = () => {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Start typing your content here...',
}),
],
content: '',
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor} maxW="3xl">
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
<Control.Underline />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.BulletList />
<Control.OrderedList />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.Undo />
<Control.Redo />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}
Task list
The editor styles task lists out of the box. Add @tiptap/extension-task-list
and @tiptap/extension-task-item, then build the controls with
createBooleanControl.
'use client'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {
RichTextEditor,
createBooleanControl,
} from '#components/ui/rich-text-editor'
import { LuArrowLeft, LuArrowRight, LuListChecks, LuPlus } from 'react-icons/lu'
export const RichTextEditorWithTask = () => {
const editor = useEditor({
extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true })],
content: `
<h2>Project Tasks</h2>
<p>Use the toolbar to manage your tasks:</p>
<ul data-type="taskList">
<li data-type="taskItem" data-checked="false">Write introduction</li>
<li data-type="taskItem" data-checked="true">Set up editor</li>
<li data-type="taskItem" data-checked="false">Add toolbar controls</li>
</ul>
<p>Keep adding tasks to track your progress!</p>
`,
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor} maxW="3xl">
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<ToggleTaskList />
<IndentTask />
<OutdentTask />
<AddTask />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}
const ToggleTaskList = createBooleanControl({
label: 'Toggle Task List',
icon: LuListChecks,
command: (editor) => editor.chain().focus().toggleTaskList().run(),
getVariant: (editor) => (editor.isActive('taskList') ? 'subtle' : 'ghost'),
})
const IndentTask = createBooleanControl({
label: 'Indent Task',
icon: LuArrowRight,
command: (editor) => editor.chain().focus().sinkListItem('taskItem').run(),
getVariant: (editor) => (editor.isActive('taskItem') ? 'subtle' : 'ghost'),
})
const OutdentTask = createBooleanControl({
label: 'Outdent Task',
icon: LuArrowLeft,
command: (editor) => editor.chain().focus().liftListItem('taskItem').run(),
getVariant: (editor) => (editor.isActive('taskItem') ? 'subtle' : 'ghost'),
})
const AddTask = createBooleanControl({
label: 'Add Task',
icon: LuPlus,
command: (editor) =>
editor
.chain()
.focus()
.insertContent(
`<li data-type="taskItem" data-checked="false">New task</li>`,
)
.run(),
getVariant: (editor) => (editor.isActive('taskItem') ? 'subtle' : 'ghost'),
})
Images
Add @tiptap/extension-image and insert images with setImage. This example
combines a URL field and a file upload in a dialog.
'use client'
import { useState } from 'react'
import { Box, Icon, Tabs } from '@chakra-ui/react'
import Image from '@tiptap/extension-image'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Button } from '#components/ui/button'
import { Dialog } from '#components/ui/dialog'
import { FileUpload } from '#components/ui/file-upload'
import { Input } from '#components/ui/input'
import {
Control,
RichTextEditor,
useRichTextEditorContext,
} from '#components/ui/rich-text-editor'
import { LuImage, LuLink, LuUpload } from 'react-icons/lu'
export const RichTextEditorWithImage = () => {
const editor = useEditor({
content: `
<h2>Release notes</h2>
<img src="https://images.unsplash.com/photo-1618477388954-7852f32655ec?w=800&q=80" alt="Abstract gradient" />
<p>Drop an image straight into the document, or embed one from a URL.</p>
`,
extensions: [StarterKit, Image],
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor} maxW="3xl">
<RichTextEditor.Toolbar>
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
<Control.Strikethrough />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.BulletList />
<Control.OrderedList />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<InsertImageControl />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}
function InsertImageControl() {
const { editor } = useRichTextEditorContext()
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [files, setFiles] = useState<File[]>([])
if (!editor) return null
return (
<>
<Control.ButtonControl
icon={<LuImage />}
label="Insert Image"
variant="ghost"
onClick={() => setOpen(true)}
/>
<Dialog.Root open={open} onOpenChange={(e) => setOpen(e.open)}>
<Dialog.Content maxW="lg">
<Dialog.Header>
<Dialog.Title>Insert Image</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Tabs.Root defaultValue="url">
<Tabs.List>
<Tabs.Trigger value="url">
<LuLink /> Embed URL
</Tabs.Trigger>
<Tabs.Trigger value="upload">
<LuUpload /> Upload File
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="url">
<Box display="flex" gap="2" mt="4">
<Input
placeholder="Enter image URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button
onClick={() => {
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
setUrl('')
setOpen(false)
}}
>
Insert
</Button>
</Box>
</Tabs.Content>
<Tabs.Content value="upload">
<FileUpload.Root
alignItems="stretch"
maxFiles={1}
accept="image/*"
onFileAccept={(accepted) => {
const uploaded = accepted.files ?? []
setFiles(uploaded)
if (uploaded[0]) {
const objectUrl = URL.createObjectURL(uploaded[0])
editor.chain().focus().setImage({ src: objectUrl }).run()
setOpen(false)
}
}}
>
<FileUpload.Dropzone mt="4">
<Icon size="md" color="fg.muted">
<LuUpload />
</Icon>
<Box>Drag and drop a file here</Box>
<Box color="fg.muted">.png, .jpg up to 5MB</Box>
</FileUpload.Dropzone>
<FileUpload.List files={files} />
</FileUpload.Root>
</Tabs.Content>
</Tabs.Root>
</Dialog.Body>
<Dialog.Footer>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</>
)
}
Bubble menu
Wrap a toolbar in Tiptap's BubbleMenu to float the controls above the
selection. Use the floating toolbar variant so it renders as a panel.
'use client'
import { useEditor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import { Control, RichTextEditor } from '#components/ui/rich-text-editor'
export const RichTextEditorWithBubbleMenu = () => {
const editor = useEditor({
extensions: [StarterKit.configure({ link: { openOnClick: false } })],
content: sampleContent,
shouldRerenderOnTransaction: true,
immediatelyRender: false,
})
if (!editor) return null
return (
<RichTextEditor.Root editor={editor} maxW="3xl">
<BubbleMenu editor={editor}>
<RichTextEditor.Toolbar variant="floating">
<RichTextEditor.ControlGroup>
<Control.Bold />
<Control.Italic />
<Control.Underline />
<Control.Strikethrough />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.Code />
<Control.Link />
</RichTextEditor.ControlGroup>
<RichTextEditor.ControlGroup>
<Control.BulletList />
<Control.OrderedList />
</RichTextEditor.ControlGroup>
</RichTextEditor.Toolbar>
</BubbleMenu>
<RichTextEditor.Content />
</RichTextEditor.Root>
)
}
const sampleContent = `
<h2>Select some text to see the bubble menu</h2>
<p>The <strong>Bold</strong>, <em>Italic</em>, <u>Underline</u> and <s>Strikethrough</s> controls appear right above the selection.</p>
<ul>
<li>Try selecting text within this list item.</li>
<li>Use the list buttons to switch between bullet and ordered lists.</li>
</ul>
`
Props
Root
| Prop | Default | Type |
|---|---|---|
editor * | Editor | nullThe Tiptap editor instance, usually created with `useEditor`. Renders nothing until the editor is ready. | |
disabled | booleanDims the content area and blocks pointer events. Disable the editor itself with Tiptap's `editable` option. |
Toolbar
| Prop | Default | Type |
|---|---|---|
variant | 'fixed' | 'fixed' | 'sticky' | 'floating'`fixed` sits above the content, `sticky` follows the scroll position, `floating` is a panel for use inside a bubble menu. |
stickyOffset | '0px' | stringDistance from the top of the scroll container, used by the `sticky` variant. |
Content
| Prop | Default | Type |
|---|---|---|
editorProps | EditorPropsProseMirror view props forwarded to Tiptap's `EditorContent`. |
ButtonControl
| Prop | Default | Type |
|---|---|---|
label * | stringAccessible name for the button, also shown as its tooltip. | |
icon * | React.ReactNodeThe icon rendered inside the button. |
createBooleanControl
| Prop | Default | Type |
|---|---|---|
label * | stringAccessible name for the control, also shown as its tooltip. | |
icon * | React.ElementTypeThe icon component rendered inside the button. | |
command * | (editor: Editor) => voidRuns when the control is clicked, usually a chained Tiptap command. | |
getVariant | (editor: Editor) => IconButtonProps['variant']Derives the button variant from the editor state, typically `subtle` when the mark is active. | |
isDisabled | (editor: Editor) => booleanDisables the control based on the editor state, for example when the command can't run. | |
getProps | (editor: Editor) => Record<string, any>Props derived from the editor state. Takes precedence over `getVariant`. |
createSelectControl
| Prop | Default | Type |
|---|---|---|
label * | stringAccessible name for the select, also shown as its tooltip. | |
options * | SelectOption[]The selectable options, each with a `value`, `label` and optional `icon`. | |
getValue * | (editor: Editor) => stringDerives the selected option value from the editor state. | |
command * | (editor: Editor, value: string) => voidRuns when an option is selected. | |
placeholder | 'Select' | stringShown when the current editor state matches none of the options. |
renderValue | (value: string, option?: SelectOption) => React.ReactNodeCustomizes how the selected option is rendered in the trigger. | |
width | stringWidth of the select trigger. |
createSwatchControl
| Prop | Default | Type |
|---|---|---|
label * | stringAccessible name for the control, also shown as its tooltip. | |
swatches * | SwatchOption[]The colors shown in the popover, each with a `value` and a `color`. | |
getValue * | (editor: Editor) => stringDerives the active color from the editor state. | |
command * | (editor: Editor, value: string) => voidRuns when a swatch is picked. | |
showRemove | false | booleanAdds a close button to the popover that clears the color. |
onRemove | (editor: Editor) => voidRuns when the remove button is clicked. | |
icon | React.ElementTypeIcon rendered above the color bar in the trigger. |