rapidUI

Rich Text Editor

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.json

Installs 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.0

Add the components it builds on: button, field, input, label, popover, tooltip.

Copy the source into your project:

components/ui/rich-text-editor/extensions/font-size.ts
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}`,
							}
						},
					},
				},
			},
		]
	},
})
components/ui/rich-text-editor/extensions/index.ts
export * from './font-size'
components/ui/rich-text-editor/index.ts
export * from './rich-text-editor'
components/ui/rich-text-editor/rich-text-editor.tsx
'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.

  • Rich Text Content
  • Textarea