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
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 { Bold, Italic, Link as LinkIcon, Unlink } from "lucide-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
}
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-md 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"
>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 pointer-coarse:size-9",
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 pointer-coarse:size-9",
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 pointer-coarse:size-9",
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"
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>
)
}