Rich Text Editor
Tiptap editor with a formatting toolbar (headings, lists, links, highlight, alignment) and a hover bubble to edit or unlink links inline. Outputs HTML, controlled or uncontrolled, with composable parts.
Example
Installation
npx shadcn@latest add https://hirael.com/r/rich-text-editor.jsonUsage
import {
RichTextEditor,
RichTextEditorToolbar,
RichTextEditorButton,
RichTextEditorSeparator,
RichTextEditorContent,
RichTextEditorLinkPopover,
RichTextEditorLinkBubble,
useRichTextEditor,
} from "@/components/rich-text-editor"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<RichTextEditor />
| Prop | Type | Default |
|---|---|---|
value | string | none |
onChange | ((value: string) => void) | none |
defaultValue | string | '' |
placeholder | string | 'Write something…' |
editable | boolean | true |
disabled | boolean | false |
onFocus | (() => void) | none |
onBlur | (() => void) | none |
className | string | none |
children | React.ReactNode | none |
<RichTextEditorToolbar />
+ native element propsNo props of its own; forwards everything to the underlying element.
<RichTextEditorButton />
| Prop | Type | Default |
|---|---|---|
tooltip* | string | none |
pressed | boolean | false |
onPressedChange | (() => void) | none |
disabled | boolean | none |
className | string | none |
children* | React.ReactNode | none |
<RichTextEditorSeparator />
+ native element propsNo props of its own; forwards everything to the underlying element.
<RichTextEditorContent />
+ native element propsNo props of its own; forwards everything to the underlying element.
<RichTextEditorLinkPopover />
No configurable props.
<RichTextEditorLinkBubble />
No configurable props.
Component source
'use client';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { Extension } from '@tiptap/core';
import Highlight from '@tiptap/extension-highlight';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { type Editor, EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Check,
Code,
ExternalLink,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Minus,
Pencil,
Pilcrow,
Quote,
Redo,
Strikethrough,
Trash2,
Underline as UnderlineIcon,
Undo,
Unlink,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { Skeleton } from '@/registry/hirael/bases/radix/ui/skeleton';
import { Toggle } from '@/registry/hirael/bases/radix/ui/toggle';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/registry/hirael/bases/radix/ui/tooltip';
const fakeSelectionPluginKey = new PluginKey('rich-text-editor-fake-selection');
const SelectionHighlight = Extension.create({
name: 'richTextEditorSelectionHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
key: fakeSelectionPluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, old) => {
const meta = tr.getMeta(fakeSelectionPluginKey) as { from: number; to: number } | 'clear' | undefined;
if (meta === 'clear') return DecorationSet.empty;
if (meta) {
const deco = Decoration.inline(meta.from, meta.to, {
class: 'fake-selection',
});
return DecorationSet.create(tr.doc, [deco]);
}
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
const showFakeSelection = (editor: Editor) => {
const { from, to } = editor.state.selection;
if (from === to) return;
editor.view.dispatch(editor.state.tr.setMeta(fakeSelectionPluginKey, { from, to }));
};
const clearFakeSelection = (editor: Editor) => {
editor.view.dispatch(editor.state.tr.setMeta(fakeSelectionPluginKey, 'clear'));
};
const RichTextEditorContext = React.createContext<Editor | null>(null);
const useRichTextEditor = (): Editor => {
const editor = React.useContext(RichTextEditorContext);
if (!editor) {
throw new Error('Rich text editor parts must be rendered inside <RichTextEditor>.');
}
return editor;
};
const contentClassName = cn(
'text-foreground selection:bg-primary/20',
'[&_.ProseMirror_h1]:mt-6 [&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-bold max-sm:[&_.ProseMirror_h1]:text-2xl',
'[&_.ProseMirror_h2]:mt-5 [&_.ProseMirror_h2]:mb-2 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-semibold max-sm:[&_.ProseMirror_h2]:text-xl',
'[&_.ProseMirror_h3]:mt-4 [&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-semibold max-sm:[&_.ProseMirror_h3]:text-lg',
'[&_.ProseMirror_p]:my-3 [&_.ProseMirror_p]:text-base [&_.ProseMirror_p]:leading-7 [&_.ProseMirror_p:first-child]:mt-0 [&_.ProseMirror_p:last-child]:mb-0',
'[&_.ProseMirror_ul]:my-3 [&_.ProseMirror_ul]:ms-6 [&_.ProseMirror_ul]:list-disc',
'[&_.ProseMirror_ol]:my-3 [&_.ProseMirror_ol]:ms-6 [&_.ProseMirror_ol]:list-decimal',
'[&_.ProseMirror_li]:my-1 [&_.ProseMirror_li_p]:my-0',
'[&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-sm',
'[&_.ProseMirror_pre]:my-3 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:border-border [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 max-sm:[&_.ProseMirror_pre]:text-xs',
'[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0 [&_.ProseMirror_pre_code]:text-sm [&_.ProseMirror_pre_code]:leading-relaxed',
'[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-s-4 [&_.ProseMirror_blockquote]:border-primary [&_.ProseMirror_blockquote]:ps-4 [&_.ProseMirror_blockquote]:italic [&_.ProseMirror_blockquote]:text-muted-foreground',
'[&_.ProseMirror_hr]:my-6 [&_.ProseMirror_hr]:border-t [&_.ProseMirror_hr]:border-border',
'[&_.ProseMirror_mark]:rounded-sm [&_.ProseMirror_mark]:bg-warm/40 [&_.ProseMirror_mark]:px-0.5 [&_.ProseMirror_mark]:text-warm-foreground',
'[&_.ProseMirror_.fake-selection]:rounded-sm [&_.ProseMirror_.fake-selection]:bg-primary/20',
'[&_.ProseMirror_p.is-editor-empty:first-child]:before:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child]:before:float-start [&_.ProseMirror_p.is-editor-empty:first-child]:before:h-0 [&_.ProseMirror_p.is-editor-empty:first-child]:before:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]',
);
export interface RichTextEditorProps {
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
placeholder?: string;
editable?: boolean;
disabled?: boolean;
onFocus?: () => void;
onBlur?: () => void;
className?: string;
children?: React.ReactNode;
}
const RichTextEditor = ({
value,
onChange,
defaultValue = '',
placeholder = 'Write something…',
editable = true,
disabled = false,
onFocus,
onBlur,
className,
children,
}: RichTextEditorProps) => {
const isEditable = editable && !disabled;
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
const onFocusRef = React.useRef(onFocus);
onFocusRef.current = onFocus;
const onBlurRef = React.useRef(onBlur);
onBlurRef.current = onBlur;
const placeholderRef = React.useRef(placeholder);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
bulletList: { keepMarks: true, keepAttributes: false },
orderedList: { keepMarks: true, keepAttributes: false },
link: {
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline underline-offset-2 hover:text-primary/80',
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Highlight.configure({ multicolor: false }),
Placeholder.configure({
placeholder: () => placeholderRef.current,
emptyEditorClass: 'is-editor-empty',
}),
SelectionHighlight,
],
content: value ?? defaultValue,
editable: isEditable,
immediatelyRender: false,
onUpdate: ({ editor: e }) => onChangeRef.current?.(e.getHTML()),
onFocus: () => onFocusRef.current?.(),
onBlur: () => onBlurRef.current?.(),
editorProps: {
attributes: {
class: 'focus:outline-none min-h-[150px] px-3 py-2 text-base md:text-sm',
},
},
});
React.useEffect(() => {
if (!editor) return;
if (value !== undefined && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [value, editor]);
React.useEffect(() => {
if (editor && editor.isEditable !== isEditable) {
editor.setEditable(isEditable);
}
}, [isEditable, editor]);
React.useEffect(() => {
if (placeholderRef.current === placeholder) return;
placeholderRef.current = placeholder;
if (editor) editor.view.dispatch(editor.state.tr);
}, [placeholder, editor]);
if (!editor) {
return (
<div
data-slot="rich-text-editor"
className={cn('grow rounded-md border border-input bg-transparent shadow-xs', className)}
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
{Array.from({ length: 18 }).map((_, i) => (
<Skeleton key={i} className="size-7 rounded" />
))}
</div>
<div className="min-h-[150px] space-y-2.5 px-3 py-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
);
}
return (
<RichTextEditorContext.Provider value={editor}>
<div
data-slot="rich-text-editor"
aria-disabled={disabled || undefined}
className={cn(
'rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
disabled && 'cursor-not-allowed opacity-50',
className,
)}
>
{children ?? (
<>
{isEditable && <RichTextEditorToolbar />}
<RichTextEditorContent />
{isEditable && <RichTextEditorLinkBubble />}
</>
)}
</div>
</RichTextEditorContext.Provider>
);
};
const RichTextEditorContent = ({ className, ...props }: React.ComponentProps<'div'>) => {
const editor = useRichTextEditor();
return (
<div data-slot="rich-text-editor-content" className={cn(contentClassName, className)} {...props}>
<EditorContent editor={editor} />
</div>
);
};
const RichTextEditorToolbar = ({ className, children, ...props }: React.ComponentProps<'div'>) => {
return (
<TooltipProvider>
<div
data-slot="rich-text-editor-toolbar"
className={cn('flex flex-wrap items-center gap-0.5 border-b border-border p-1', className)}
{...props}
>
{children ?? <DefaultToolbar />}
</div>
</TooltipProvider>
);
};
export interface RichTextEditorButtonProps {
tooltip: string;
pressed?: boolean;
onPressedChange?: () => void;
disabled?: boolean;
className?: string;
children: React.ReactNode;
}
const RichTextEditorButton = ({
tooltip,
pressed = false,
onPressedChange,
disabled,
className,
children,
}: RichTextEditorButtonProps) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Toggle
data-slot="rich-text-editor-button"
size="sm"
pressed={pressed}
onPressedChange={onPressedChange}
disabled={disabled}
className={className}
>
{children}
</Toggle>
</TooltipTrigger>
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
);
};
const RichTextEditorSeparator = ({ className, ...props }: React.ComponentProps<typeof Separator>) => {
return (
<Separator
data-slot="rich-text-editor-separator"
orientation="vertical"
className={cn('mx-1 h-6', className)}
{...props}
/>
);
};
const RichTextEditorLinkPopover = () => {
const editor = useRichTextEditor();
const [open, setOpen] = React.useState(false);
const [url, setUrl] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null);
const isLink = useEditorState({
editor,
selector: ({ editor }) => editor.isActive('link'),
});
const handleOpen = (nextOpen: boolean) => {
if (nextOpen) {
const existing = editor.getAttributes('link').href as string | undefined;
setUrl(existing ?? '');
showFakeSelection(editor);
} else {
clearFakeSelection(editor);
}
setOpen(nextOpen);
};
const applyLink = () => {
clearFakeSelection(editor);
if (url.trim() === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
}
setOpen(false);
};
const removeLink = () => {
clearFakeSelection(editor);
editor.chain().focus().extendMarkRange('link').unsetLink().run();
setOpen(false);
};
return (
<Popover open={open} onOpenChange={handleOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Toggle
data-slot="rich-text-editor-link-trigger"
size="sm"
pressed={isLink}
onPressedChange={() => handleOpen(!open)}
>
<LinkIcon className="size-4" />
</Toggle>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">Link</TooltipContent>
</Tooltip>
<PopoverContent
data-slot="rich-text-editor-link-popover"
className="w-80 p-3"
align="start"
onOpenAutoFocus={(e) => {
e.preventDefault();
inputRef.current?.focus();
}}
>
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
applyLink();
}}
>
<Input
ref={inputRef}
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="h-8 text-sm"
/>
<Button type="submit" variant="ghost" size="icon-sm">
<Check className="size-4" />
</Button>
{isLink && (
<Button type="button" variant="ghost" size="icon-sm" onClick={removeLink}>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};
interface LinkTarget {
el: HTMLAnchorElement;
href: string;
}
const RichTextEditorLinkBubble = () => {
const editor = useRichTextEditor();
const [target, setTarget] = React.useState<LinkTarget | null>(null);
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState('');
const editingRef = React.useRef(false);
const hideTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const rafRef = React.useRef(0);
const [, reposition] = React.useReducer((n: number) => n + 1, 0);
React.useEffect(() => {
editingRef.current = editing;
}, [editing]);
const clearHide = React.useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
React.useEffect(
() => () => {
cancelAnimationFrame(rafRef.current);
clearHide();
},
[clearHide],
);
const linkFromNode = React.useCallback((node: Node | null | undefined): HTMLAnchorElement | null => {
const el = node instanceof HTMLElement ? node : node?.parentElement;
return el?.closest('a') ?? null;
}, []);
const linkAtSelection = React.useCallback((): HTMLAnchorElement | null => {
if (!editor.isEditable || !editor.isActive('link')) return null;
return linkFromNode(editor.view.domAtPos(editor.state.selection.from).node);
}, [editor, linkFromNode]);
const show = React.useCallback((el: HTMLAnchorElement) => {
const href = el.getAttribute('href') ?? '';
setTarget((prev) => (prev && prev.el === el && prev.href === href ? prev : { el, href }));
}, []);
const scheduleHide = React.useCallback(() => {
clearHide();
hideTimer.current = setTimeout(() => {
if (editingRef.current) return;
const el = linkAtSelection();
if (el) show(el);
else setTarget(null);
}, 200);
}, [clearHide, linkAtSelection, show]);
React.useEffect(() => {
const onSelect = () => {
if (editingRef.current) return;
const el = linkAtSelection();
if (el) {
clearHide();
show(el);
} else {
scheduleHide();
}
};
editor.on('selectionUpdate', onSelect);
return () => {
editor.off('selectionUpdate', onSelect);
};
}, [editor, linkAtSelection, show, clearHide, scheduleHide]);
React.useEffect(() => {
const dom = editor.view.dom;
const onOver = (e: Event) => {
if (!editor.isEditable) return;
const el = linkFromNode(e.target as Node | null);
if (el) {
clearHide();
show(el);
}
};
const onOut = () => scheduleHide();
dom.addEventListener('mouseover', onOver);
dom.addEventListener('mouseout', onOut);
return () => {
dom.removeEventListener('mouseover', onOver);
dom.removeEventListener('mouseout', onOut);
};
}, [editor, linkFromNode, show, clearHide, scheduleHide]);
React.useEffect(() => {
if (!target) return;
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [target]);
React.useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
if (typeof document === 'undefined' || !target || !target.el.isConnected) {
return null;
}
const rect = target.el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return null;
const below = rect.top < 64;
const selectLink = () => {
const from = editor.view.posAtDOM(target.el, 0);
const to = from + (target.el.textContent?.length ?? 0);
return editor.chain().focus().setTextSelection({ from, to }).extendMarkRange('link');
};
const applyEdit = () => {
const href = draft.trim();
setEditing(false);
if (href === '') {
selectLink().unsetLink().run();
setTarget(null);
return;
}
selectLink().setLink({ href }).run();
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const el = linkAtSelection();
if (el) show(el);
else setTarget(null);
});
};
const removeLink = () => {
selectLink().unsetLink().run();
setEditing(false);
setTarget(null);
};
const closeBubble = () => {
setEditing(false);
setTarget(null);
editor.commands.focus();
};
return createPortal(
<div
data-slot="rich-text-editor-link-bubble"
role="dialog"
aria-label="Link"
onMouseEnter={clearHide}
onMouseLeave={scheduleHide}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeBubble();
}
}}
style={{
position: 'fixed',
top: below ? rect.bottom + 8 : rect.top - 8,
left: Math.max(8, rect.left),
transform: below ? undefined : 'translateY(-100%)',
}}
className="z-50 flex max-w-[min(22rem,calc(100vw-1rem))] items-center gap-1 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
>
{editing ? (
<form
className="flex items-center gap-1"
onSubmit={(e) => {
e.preventDefault();
applyEdit();
}}
>
<Input
ref={inputRef}
type="url"
dir="ltr"
placeholder="https://example.com"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="h-8 w-56 text-sm"
/>
<Button type="submit" variant="ghost" size="icon-sm" aria-label="Apply">
<Check className="size-4" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Cancel" onClick={() => setEditing(false)}>
<X className="size-4" />
</Button>
</form>
) : (
<>
<a
href={target.href}
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="flex min-w-0 items-center gap-1.5 px-2 text-sm text-primary underline-offset-2 hover:underline"
>
<ExternalLink className="size-3.5 shrink-0" />
<span className="truncate">{target.href}</span>
</a>
<RichTextEditorSeparator className="h-5" />
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Edit link"
onClick={() => {
setDraft(target.href);
setEditing(true);
}}
>
<Pencil className="size-4" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Remove link" onClick={removeLink}>
<Unlink className="size-4" />
</Button>
</>
)}
</div>,
document.body,
);
};
const DefaultToolbar = () => {
const editor = useRichTextEditor();
const state = useEditorState({
editor,
selector: ({ editor }) => ({
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
isParagraph: editor.isActive('paragraph'),
isHeading1: editor.isActive('heading', { level: 1 }),
isHeading2: editor.isActive('heading', { level: 2 }),
isHeading3: editor.isActive('heading', { level: 3 }),
isBold: editor.isActive('bold'),
isItalic: editor.isActive('italic'),
isUnderline: editor.isActive('underline'),
isStrike: editor.isActive('strike'),
isCode: editor.isActive('code'),
isHighlight: editor.isActive('highlight'),
isAlignLeft: editor.isActive({ textAlign: 'left' }),
isAlignCenter: editor.isActive({ textAlign: 'center' }),
isAlignRight: editor.isActive({ textAlign: 'right' }),
isAlignJustify: editor.isActive({ textAlign: 'justify' }),
isBulletList: editor.isActive('bulletList'),
isOrderedList: editor.isActive('orderedList'),
isBlockquote: editor.isActive('blockquote'),
}),
});
return (
<>
<RichTextEditorButton
tooltip="Undo"
onPressedChange={() => editor.chain().focus().undo().run()}
disabled={!state.canUndo}
>
<Undo className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Redo"
onPressedChange={() => editor.chain().focus().redo().run()}
disabled={!state.canRedo}
>
<Redo className="size-4" />
</RichTextEditorButton>
<RichTextEditorSeparator />
<RichTextEditorButton
tooltip="Paragraph"
pressed={state.isParagraph}
onPressedChange={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Heading 1"
pressed={state.isHeading1}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1 className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Heading 2"
pressed={state.isHeading2}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Heading 3"
pressed={state.isHeading3}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3 className="size-4" />
</RichTextEditorButton>
<RichTextEditorSeparator />
<RichTextEditorButton
tooltip="Bold"
pressed={state.isBold}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Italic"
pressed={state.isItalic}
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Underline"
pressed={state.isUnderline}
onPressedChange={() => editor.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Strikethrough"
pressed={state.isStrike}
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Code"
pressed={state.isCode}
onPressedChange={() => editor.chain().focus().toggleCode().run()}
>
<Code className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Highlight"
pressed={state.isHighlight}
onPressedChange={() => editor.chain().focus().toggleHighlight().run()}
>
<Highlighter className="size-4" />
</RichTextEditorButton>
<RichTextEditorSeparator />
<RichTextEditorLinkPopover />
<RichTextEditorSeparator />
<RichTextEditorButton
tooltip="Align left"
pressed={state.isAlignLeft}
onPressedChange={() => editor.chain().focus().setTextAlign('left').run()}
>
<AlignLeft className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Align center"
pressed={state.isAlignCenter}
onPressedChange={() => editor.chain().focus().setTextAlign('center').run()}
>
<AlignCenter className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Align right"
pressed={state.isAlignRight}
onPressedChange={() => editor.chain().focus().setTextAlign('right').run()}
>
<AlignRight className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Justify"
pressed={state.isAlignJustify}
onPressedChange={() => editor.chain().focus().setTextAlign('justify').run()}
>
<AlignJustify className="size-4" />
</RichTextEditorButton>
<RichTextEditorSeparator />
<RichTextEditorButton
tooltip="Bullet list"
pressed={state.isBulletList}
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Ordered list"
pressed={state.isOrderedList}
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Blockquote"
pressed={state.isBlockquote}
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="size-4" />
</RichTextEditorButton>
<RichTextEditorButton
tooltip="Horizontal rule"
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
>
<Minus className="size-4" />
</RichTextEditorButton>
</>
);
};
export {
RichTextEditor,
RichTextEditorToolbar,
RichTextEditorButton,
RichTextEditorSeparator,
RichTextEditorContent,
RichTextEditorLinkPopover,
RichTextEditorLinkBubble,
useRichTextEditor,
};
Dependencies
shadcn registry
buttoninputpopoverseparatorskeletontoggletooltip
npm
@tiptap/react@tiptap/pm@tiptap/core@tiptap/starter-kit@tiptap/extension-text-align@tiptap/extension-highlight@tiptap/extension-placeholderlucide-react