Tiptap rich text editor with formatting toolbar, links, alignment and font size.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/rich-text-editor.jsonInstalls to @/components/ui/rich-text-editor.
Manual installation
Install the dependencies:
pnpm add @tiptap/extension-link@^3.3.0 @tiptap/extension-placeholder@^3.3.0 @tiptap/extension-text-align@^3.3.0 @tiptap/extension-text-style@^3.3.0 @tiptap/react@^3.3.0 @tiptap/starter-kit@^3.3.0 lucide-react@^1.48.0Add the components it builds on: button, field, input, label, popover, tooltip.
Copy the source into your project:
import { Extension } from '@tiptap/react'
export const FontSize = Extension.create({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: ['textStyle'],
attributes: {
fontSize: {
default: null,
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
renderHTML: (attributes) => {
if (!attributes.fontSize) return {}
return {
style: `font-size: ${attributes.fontSize}`,
}
},
},
},
},
]
},
})export * from './font-size'export * from './rich-text-editor''use client'
// NOTE: UI strings are hardcoded in English. This is a template component — wire it to your app's i18n when adopting it.
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyleKit } from '@tiptap/extension-text-style'
import { type Editor, EditorContent, Extension, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Italic,
Link2,
List,
ListOrdered,
Quote,
Strikethrough,
Type,
} from 'lucide-react'
import * as React from 'react'
import { useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { FontSize } from './extensions'
export type MenuItemType =
| 'bold'
| 'italic'
| 'strike'
| 'bulletList'
| 'orderedList'
| 'blockquote'
| 'alignLeft'
| 'alignCenter'
| 'alignRight'
| 'link'
| 'fontSize'
interface MenuItemConfig {
type: MenuItemType
icon: React.ReactNode
tooltip: string
isActive?: (editor: Editor) => boolean
onClick?: (editor: Editor) => void
dropdown?: ({ editor }: { editor: Editor | null }) => React.JSX.Element
}
const defaultMenuItems: MenuItemConfig[] = [
{
type: 'bold',
icon: <Bold />,
tooltip: 'Bold',
isActive: (editor) => editor.isActive('bold'),
onClick: (editor) => editor.chain().focus().toggleBold().run(),
},
{
type: 'italic',
icon: <Italic />,
tooltip: 'Italic',
isActive: (editor) => editor.isActive('italic'),
onClick: (editor) => editor.chain().focus().toggleItalic().run(),
},
{
type: 'strike',
icon: <Strikethrough />,
tooltip: 'Strikethrough',
isActive: (editor) => editor.isActive('strike'),
onClick: (editor) => editor.chain().focus().toggleStrike().run(),
},
{
type: 'bulletList',
icon: <List />,
tooltip: 'Bullet list',
isActive: (editor) => editor.isActive('bulletList'),
onClick: (editor) => editor.chain().focus().toggleBulletList().run(),
},
{
type: 'orderedList',
icon: <ListOrdered />,
tooltip: 'Numbered list',
isActive: (editor) => editor.isActive('orderedList'),
onClick: (editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
type: 'blockquote',
icon: <Quote />,
tooltip: 'Blockquote',
isActive: (editor) => editor.isActive('blockquote'),
onClick: (editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
type: 'alignLeft',
icon: <AlignLeft />,
tooltip: 'Align left',
isActive: (editor) => editor.isActive({ textAlign: 'left' }),
onClick: (editor) => editor.chain().focus().setTextAlign('left').run(),
},
{
type: 'alignCenter',
icon: <AlignCenter />,
tooltip: 'Center text',
isActive: (editor) => editor.isActive({ textAlign: 'center' }),
onClick: (editor) => editor.chain().focus().setTextAlign('center').run(),
},
{
type: 'alignRight',
icon: <AlignRight />,
tooltip: 'Align right',
isActive: (editor) => editor.isActive({ textAlign: 'right' }),
onClick: (editor) => editor.chain().focus().setTextAlign('right').run(),
},
{
type: 'fontSize',
icon: <Type />,
tooltip: 'Font size',
dropdown: ({ editor }: { editor: Editor | null }) => <FontSizeDropdown editor={editor} />,
},
]
// Pressed toolbar buttons keep the secondary icon colour on the lighter accent background
const PRESSED_CLASS = 'bg-accent hover:bg-[color-mix(in_oklch,var(--accent),var(--secondary-foreground)_8%)]'
const FONT_SIZES = ['12px', '14px', '16px', '18px', '20px', '24px', '32px']
const FontSizeDropdown = ({ editor }: { editor: Editor | null }) => {
const [isOpen, setIsOpen] = React.useState(false)
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
// Keep the click away from the wrapper's click-to-focus handler
onClick={(e) => e.stopPropagation()}
render={<Button type="button" size="icon-sm" variant="ghost" aria-label="Font size" />}
/>
}
>
<Type />
</TooltipTrigger>
<TooltipContent>Font size</TooltipContent>
</Tooltip>
<PopoverContent className="w-40 gap-0.5 p-1">
<div className="flex flex-col gap-0.5">
{FONT_SIZES.map((size) => (
<Button
key={size}
type="button"
variant="ghost"
size="sm"
className="justify-start font-normal"
onClick={() => {
editor?.chain().focus().setMark('textStyle', { fontSize: size }).run()
setIsOpen(false)
}}
>
{size}
</Button>
))}
</div>
</PopoverContent>
</Popover>
)
}
interface RichTextEditorProps {
name?: string
label?: string
value?: string
onChange: (value: string) => void
placeholder?: string
htmlPlaceholder?: string
excludeMenuItems?: MenuItemType[]
className?: string
/** marks the editor as invalid (destructive border + ring) */
'aria-invalid'?: boolean | 'true' | 'false'
}
/**
* The link to store for what the user typed, or null when it is not a web address.
* `new URL` alone accepts almost anything once https:// is added (browsers encode spaces
* as %20), so the host also has to look like a domain: no spaces and at least one dot.
*/
function toLinkHref(input: string): string | null {
const value = input.trim()
try {
const url = new URL(/^https?:\/\//.test(value) ? value : `https://${value}`)
return /^[^\s%]+\.[^\s%.]+$/.test(url.hostname) ? url.toString() : null
} catch {
return null
}
}
const LinkDropdown = ({ editor }: { editor: Editor | null }) => {
const [url, setUrl] = React.useState<string>('')
const urlId = React.useId()
const urlErrorId = React.useId()
const [isUrlInvalid, setIsUrlInvalid] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [isTextSelected, setIsTextSelected] = React.useState(false)
const [isLinkActive, setIsLinkActive] = React.useState(false)
// Listen to selection changes to update button state
React.useEffect(() => {
if (!editor) return
const updateSelectionState = () => {
setIsTextSelected(editor.state.selection.content().size > 0)
setIsLinkActive(editor.isActive('link'))
}
// Initial state
updateSelectionState()
// Listen to selection and transaction updates
editor.on('selectionUpdate', updateSelectionState)
editor.on('transaction', updateSelectionState)
return () => {
editor.off('selectionUpdate', updateSelectionState)
editor.off('transaction', updateSelectionState)
}
}, [editor])
React.useEffect(() => {
if (isOpen && editor?.isActive('link')) {
setUrl(editor.getAttributes('link').href || '')
} else if (!isOpen) {
setUrl('')
}
}, [isOpen, editor])
const setLink = useCallback(
(inputUrl: string) => {
if (!editor) return
if (!inputUrl.trim()) {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
setIsOpen(false)
return
}
const href = toLinkHref(inputUrl)
if (!href) {
setIsUrlInvalid(true)
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
setUrl('')
setIsOpen(false)
},
[editor],
)
return (
<Popover modal open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
// Keep the click away from the wrapper's click-to-focus handler
onClick={(e) => e.stopPropagation()}
render={
<Button
type="button"
size="icon-sm"
disabled={!isTextSelected && !isLinkActive}
variant={isLinkActive ? 'secondary' : 'ghost'}
className={cn(isLinkActive && PRESSED_CLASS)}
aria-label="Insert or edit link"
/>
}
/>
}
>
<Link2 />
</TooltipTrigger>
<TooltipContent>{isLinkActive ? 'Edit link' : 'Insert link'}</TooltipContent>
</Tooltip>
<PopoverContent className="w-72">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor={urlId}>URL</Label>
<Input
id={urlId}
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value)
setIsUrlInvalid(false)
}}
// Enter adds the link, like submitting a form. Not a <form>: the editor usually sits
// inside the app's own form, and Enter would submit that instead.
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
setLink(url)
}}
placeholder="https://example.com"
aria-invalid={isUrlInvalid || undefined}
aria-describedby={isUrlInvalid ? urlErrorId : undefined}
/>
{isUrlInvalid && <FieldError id={urlErrorId}>Enter a valid web address, like example.com</FieldError>}
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
setIsOpen(false)
}}
type="button"
size="sm"
>
Remove link
</Button>
<Button variant="default" onClick={() => setLink(url)} type="button" size="sm">
Add link
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
const ALL_MENU_ITEMS: MenuItemType[] = [...defaultMenuItems.map((item) => item.type), 'link']
const MenuBar = ({
editor,
visibleItems = ALL_MENU_ITEMS,
}: {
editor: Editor | null
visibleItems?: MenuItemType[]
}) => {
if (!editor) return null
const filteredMenuItems = defaultMenuItems.filter((item) => visibleItems.includes(item.type))
return (
<TooltipProvider>
<div className="flex flex-wrap items-center gap-0.5 border-b p-1">
{filteredMenuItems.map((item) =>
item.dropdown ? (
<React.Fragment key={item.type}>{item.dropdown({ editor })}</React.Fragment>
) : (
<Tooltip key={item.type}>
<TooltipTrigger
render={
<Button
type="button"
size="icon-sm"
variant={item.isActive?.(editor) ? 'secondary' : 'ghost'}
className={cn(item.isActive?.(editor) && PRESSED_CLASS)}
aria-label={item.tooltip}
aria-pressed={item.isActive ? item.isActive(editor) : undefined}
onClick={() => item.onClick?.(editor)}
/>
}
>
{item.icon}
</TooltipTrigger>
<TooltipContent>{item.tooltip}</TooltipContent>
</Tooltip>
),
)}
{visibleItems.includes('link') && <LinkDropdown editor={editor} />}
</div>
</TooltipProvider>
)
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
unsetFontSize: () => ReturnType
setFontSize: (fontSize: string) => ReturnType
}
margin: {
setMargin: (margin: string) => ReturnType
}
}
}
export const RichTextEditor = ({
name,
value,
onChange,
placeholder,
htmlPlaceholder,
label,
excludeMenuItems,
className,
'aria-invalid': ariaInvalid,
...props
}: RichTextEditorProps) => {
const isContentEmpty = !value || value === '<p></p>' || value.trim() === ''
const skipSync = React.useRef(false)
const labelId = React.useId()
const editor = useEditor({
immediatelyRender: false,
// Expose the editable area as a labelled multi-line textbox for screen readers
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
...(label ? { 'aria-labelledby': labelId } : {}),
...(ariaInvalid ? { 'aria-invalid': String(ariaInvalid) } : {}),
},
},
extensions: [
StarterKit.configure({
link: false, // Disable the default Link extension since we add our own
hardBreak: {
keepMarks: true,
HTMLAttributes: {
class: 'my-hard-break',
},
},
}),
TextStyleKit,
TextAlign.configure({
types: ['paragraph'],
alignments: ['left', 'center', 'right'],
}),
Placeholder.configure({
placeholder,
showOnlyWhenEditable: true,
includeChildren: false,
emptyEditorClass: 'is-editor-empty',
emptyNodeClass: 'is-node-empty',
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline underline-offset-4 hover:decoration-2',
rel: 'noopener noreferrer',
target: '_blank',
},
}),
FontSize,
Extension.create({
name: 'margin',
addGlobalAttributes() {
return [
{
types: ['paragraph'],
attributes: {
margin: {
default: null,
parseHTML: (element) => element.style.margin,
renderHTML: (attributes) => {
if (!attributes.margin) return {}
return {
style: `margin: ${attributes.margin}`,
}
},
},
},
},
]
},
addCommands() {
return {
setMargin:
(margin: string) =>
({ editor }) => {
return editor.chain().focus().setMargin(margin).run()
},
}
},
}),
],
content: value,
onUpdate: ({ editor }) => {
skipSync.current = true
onChange(editor.getHTML())
},
...props,
})
// Sync external value changes (e.g. from form.reset()) into the editor
React.useEffect(() => {
if (skipSync.current) {
skipSync.current = false
return
}
if (editor && !editor.isDestroyed && value !== undefined) {
editor.commands.setContent(value, { emitUpdate: false })
}
}, [editor, value])
return (
<div className={cn('flex flex-col gap-2', className)} data-slot="rich-text-editor" data-field={name}>
{label && (
<Label id={labelId} onClick={() => editor?.commands.focus()}>
{label}
</Label>
)}
{/* biome-ignore lint/a11y/noStaticElementInteractions: click-to-focus wrapper for the editor */}
<div
aria-invalid={ariaInvalid}
className="w-full cursor-text overflow-hidden rounded-lg border border-border text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
// Only react to the wrapper itself, not to events bubbling from the toolbar or the portalled popovers
onClick={(e) => {
if (e.currentTarget.contains(e.target as Node)) editor?.commands.focus()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) {
e.preventDefault()
editor?.commands.focus()
}
}}
>
<MenuBar
editor={editor}
visibleItems={excludeMenuItems ? ALL_MENU_ITEMS.filter((item) => !excludeMenuItems.includes(item)) : undefined}
/>
<div className="relative">
<EditorContent
editor={editor}
className="min-h-48 px-2.5 py-2 [&_*:focus]:outline-none [&_.ProseMirror]:relative [&_.ProseMirror]:break-words [&_.ProseMirror]:overflow-hidden [&_.ProseMirror_p:empty]:min-h-[1em] [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_li]:pl-1 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-muted-foreground/30 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:italic [&_.is-editor-empty]:before:content-[attr(data-placeholder)] [&_.is-editor-empty]:before:absolute [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:h-0"
/>
{htmlPlaceholder && isContentEmpty && (
<div
className="pointer-events-none absolute inset-0 px-2.5 py-2 text-sm text-muted-foreground [&_a]:text-muted-foreground [&_p]:m-0"
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted placeholder HTML from component props
dangerouslySetInnerHTML={{ __html: htmlPlaceholder }}
/>
)}
</div>
</div>
</div>
)
}A Tiptap editor wrapped in rapidUI styling. The toolbar offers bold, italic, strikethrough, bullet and numbered lists, blockquote, left/center/right alignment, font size and links. The value is HTML; render it back with Rich Text Content.
Installs as a folder (components/ui/rich-text-editor/) with the editor and its extensions/font-size.ts.
i18n: the link popover strings ("Insert link", "Add link", …) and toolbar tooltips are hardcoded in English. This is a template component — replace them with your app's translation layer after installing.
Usage
import { RichTextEditor, type MenuItemType } from "@/components/ui/rich-text-editor"
const [html, setHtml] = useState('')
<RichTextEditor
label="Case summary"
value={html}
onChange={setHtml}
placeholder="Summarise the case for the committee…"
excludeMenuItems={['fontSize', 'blockquote']}
/>External changes to value (e.g. form.reset()) are synced into the editor without re-emitting onChange.
Examples
Default
An empty editor with a label and placeholder.
With content
A case assessment with bold text, a list and a link.
Minimal toolbar
A reply to a resident in a card, with alignment, font size, blockquote and strikethrough hidden.
HTML placeholder
A structured placeholder that shows the expected sections of a decision rationale.
Invalid
aria-invalid gives the editor a destructive border while the required text is missing.
Live preview
The HTML rendered with Rich Text Content as the applicant will see it.
Toolbar items
bold, italic, strike, bulletList, orderedList, blockquote, alignLeft, alignCenter, alignRight,
fontSize, link — hide any of them with excludeMenuItems.
Related Components
- Rich Text Content
- Textarea