Rich Text Editor
A rich text editor built on Tiptap with bold, italic, and link support.
Installation
$
Usage
import { RichTextEditor } from "@/components/ui/rich-text-editor"Examples
Default
Bold, italic, and links, with a toolbar above the writing area. value and onChange deal in HTML strings, not markdown, so store what you get back as-is.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | - | The editor content as an HTML string. |
| onChange* | (value: string) => void | - | Called with the updated HTML string on every edit. |
| onBlur | () => void | - | Called when the editor loses focus. |
| placeholder | string | - | Placeholder text shown when the editor is empty. |
| className | string | - | Extra classes for the editor's outer container. |
"use client"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import { IconBold as Bold, IconItalic as Italic, IconLink as LinkIcon, IconUnlink as Unlink } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { Input } from "@/registry/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/ui/popover"
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
onBlur?: () => void
placeholder?: string
className?: string
}
// @use-when authoring formatted text.
export function RichTextEditor({
value,
onChange,
onBlur,
placeholder,
className,
}: RichTextEditorProps) {
const [linkUrl, setLinkUrl] = useState("")
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
// Disable features we don't need
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
blockquote: false,
codeBlock: false,
code: false,
horizontalRule: false,
hardBreak: false,
link: false,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-accent underline",
},
}),
Placeholder.configure({
placeholder,
}),
],
content: value,
editorProps: {
attributes: {
class: cn(
"min-h-36 px-3 py-2 text-base md:text-sm",
"focus:outline-none",
"[&_p]:my-0 [&_p:not(:last-child)]:mb-2",
"[&_strong]:font-semibold [&_em]:italic"
),
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
onBlur: () => {
onBlur?.()
},
})
// Sync external value changes
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value)
}
}, [editor, value])
// Keep the placeholder reactive: the extension reads it once at init, so push
// later prop changes into its options and force a redraw.
useEffect(() => {
if (!editor) return
const ext = editor.extensionManager.extensions.find((e) => e.name === "placeholder")
if (ext && ext.options.placeholder !== placeholder) {
ext.options.placeholder = placeholder
editor.view.dispatch(editor.state.tr)
}
}, [editor, placeholder])
const handleSetLink = useCallback(() => {
if (!editor) return
if (linkUrl === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: linkUrl })
.run()
}
setLinkUrl("")
setLinkPopoverOpen(false)
}, [editor, linkUrl])
const handleOpenLinkPopover = useCallback(() => {
if (!editor) return
const previousUrl = editor.getAttributes("link").href || ""
setLinkUrl(previousUrl)
setLinkPopoverOpen(true)
}, [editor])
const isLinkActive = editor?.isActive("link") ?? false
return (
<div
data-slot="rich-text-editor"
className={cn(
"rounded-action border border-border bg-input-bg backdrop-blur-sm shadow-xs transition-[color,box-shadow]",
"focus-within:border-accent focus-within:ring-ring-accent focus-within:ring-1",
className
)}
>
{/* Toolbar */}
<div
role="group"
aria-label="Formatting"
className="flex items-center gap-0.5 border-b border-border px-1 py-1 h-9"
>
{/* This toolbar keeps its own touch size. `size="icon"` grows to a 44px
box on a coarse pointer and carries the 44px corner with it, but the
toolbar band is 36px tall, so a 44px control would overflow it. Each
button here holds the 36px override and therefore owes its own
corner: the 32px rung is what these rendered before and it is still
the closest fit. It is forced because every radius utility inside
the coarse media query is emitted in name order, so the wider rung
would otherwise win on nothing but its letters. */}
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-action-sm pointer-coarse:size-9 pointer-coarse:!rounded-action",
editor?.isActive("bold") && "bg-accent-100 dark:bg-accent-700 text-accent dark:text-accent-200"
)}
onClick={() => editor?.chain().focus().toggleBold().run()}
disabled={!editor}
aria-label="Bold"
aria-pressed={editor?.isActive("bold") ?? false}
>
<Bold className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-action-sm pointer-coarse:size-9 pointer-coarse:!rounded-action",
editor?.isActive("italic") && "bg-accent-100 dark:bg-accent-700 text-accent dark:text-accent-200"
)}
onClick={() => editor?.chain().focus().toggleItalic().run()}
disabled={!editor}
aria-label="Italic"
aria-pressed={editor?.isActive("italic") ?? false}
>
<Italic className="h-3 w-3" />
</Button>
<Popover open={linkPopoverOpen} onOpenChange={setLinkPopoverOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-action-sm pointer-coarse:size-9 pointer-coarse:!rounded-action",
isLinkActive && "bg-accent-100 dark:bg-accent-700 text-accent dark:text-accent-200"
)}
onClick={handleOpenLinkPopover}
disabled={!editor}
aria-label="Link"
aria-pressed={isLinkActive}
>
<LinkIcon className="h-3 w-3" />
</Button>
}
/>
<PopoverContent className="w-80 p-2" align="start">
<div className="flex gap-2">
<Input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleSetLink()
}
}}
/>
{/* Default size, not `sm` forced to h-8: that combination kept
`sm`'s 10.5px corner on a 32px box, so this button and the
Input beside it were the same height with different radii.
Only the padding is tightened. */}
<Button
type="button"
className="px-3"
onClick={handleSetLink}
>
{linkUrl ? "Set" : "Remove"}
</Button>
</div>
</PopoverContent>
</Popover>
{isLinkActive && (
<Button
type="button"
variant="ghost"
size="icon"
// `rounded-action-sm` travels with the 28px override: `size="icon"`
// ships a 32px box and its matching 12px corner, and 12px on 28px
// reads as a pill.
className="h-7 w-7 rounded-action-sm pointer-coarse:size-9 pointer-coarse:!rounded-action"
onClick={() => editor?.chain().focus().unsetLink().run()}
aria-label="Remove link"
>
<Unlink className="h-3 w-3" />
</Button>
)}
</div>
{/* Editor content area - fixed height whether editor loaded or not */}
<div className="min-h-36">
{editor && (
<EditorContent editor={editor} />
)}
</div>
</div>
)
}