Rich text editor

A lightweight editor with markdown shortcuts, a floating toolbar, a slash menu, and HTML and markdown output.

pnpm dlx shadcn@latest add @uiarc/rich-text-editor
Live · keyboard ready

0 words

  • Comments, notes, and posts where people expect markdown shortcuts and a selection toolbar.
  • Forms that need Markdown or clean HTML out without pulling in a full editor framework.
  • Short to medium documents with headings, lists, quotes, and code blocks.
  • Use textarea for plain text, or mention-input when the only rich part is tagging people.
  • Use inline-edit for a single line edited in place.
  • Use code-block to display code, not to write prose.

Installation

Add Rich text editor with the shadcn CLI, or copy the source by hand.

pnpm dlx shadcn@latest add @uiarc/rich-text-editor

Adds the component and its local dependencies, and installs motion, lucide-react. First time? Add the @uiarc registry to components.json, or use the full URL:

example.tsx
import { useState } from "react";import { RichTextEditor, type RichTextValue } from "@/registry/components/rich-text-editor/rich-text-editor"; export function PostEditor() {  const [doc, setDoc] = useState<RichTextValue | null>(null);  return (    <>      <RichTextEditor        aria-label="Post body"        placeholder="Write your update"        defaultMarkdown={"# Release notes\n\n- Faster search\n- New **dark** theme"}        onChange={setDoc}      />      <button type="button" disabled={!doc || doc.empty} onClick={() => save(doc!.markdown)}>        Publish      </button>    </>  );} declare function save(markdown: string): void;

Controlled HTML with custom undo buttons

example.tsx
const editor = useRef<RichTextEditorHandle>(null);const [html, setHtml] = useState("<p>Draft</p>");const [history, setHistory] = useState({ canUndo: false, canRedo: false }); <>  <button type="button" disabled={!history.canUndo} onClick={() => editor.current?.undo()}>Undo</button>  <button type="button" disabled={!history.canRedo} onClick={() => editor.current?.redo()}>Redo</button>  <RichTextEditor ref={editor} value={html} onChange={value => setHtml(value.html)} onHistoryChange={setHistory} /></>

API reference

6 parts. The first is the root.

RichTextEditor

A light rich text editor on contentEditable with markdown shortcuts, a floating selection toolbar that morphs into a link field, a slash menu for blocks, its own undo history, and HTML plus Markdown output.

PropTypeDefaultDescription
valuestring–Controlled HTML. The editor only rewrites its content when this differs from what it last emitted.
defaultValuestring–Initial HTML when uncontrolled. Sanitized on load.
defaultMarkdownstring–Initial content as Markdown, used when no HTML value is given.
onChange(value: RichTextValue) => void–Called after each edit with { html, markdown, text, empty }. HTML is clean: canonical tags and only href attributes.
onHistoryChange(state: { canUndo: boolean; canRedo: boolean }) => void–Reports whether undo and redo are available, for your own toolbar.
placeholderstring"Start writing"Shown when the editor is empty.
blockHintstring"Type / for blocks"The hint on an empty line while the caret is in it.
readOnlybooleanfalseTurns off editing, the toolbar, and shortcuts.
autoFocusbooleanfalseFocuses the editor on mount with the caret at the end.
aria-labelstring"Editor"Accessible name of the textbox.
classNamestring–Extra class on the root.
styleCSSProperties–Inline style on the root.

RichTextEditorHandle

Methods on the ref.

PropTypeDefaultDescription
focus() => void–Focuses the editor.
getHTML() => string–Clean HTML of the current content.
getMarkdown() => string–Current content as Markdown.
getText() => string–Plain text, one block per line.
setHTML(html: string) => void–Replaces the content with sanitized HTML as one undo step.
setMarkdown(markdown: string) => void–Replaces the content from Markdown as one undo step.
clear() => void–Empties the editor as one undo step.
undo() => void–Steps back in the editor's own history.
redo() => void–Steps forward in the editor's own history.
elementHTMLDivElement | null–The contentEditable element.

markdownToHtml

Converts Markdown to the editor's HTML: headings (levels four to six become h3), lists with nesting, quotes, fenced code, rules, and inline bold, italic, strike, code, and links.

PropTypeDefaultDescription
markdownRequiredstring–Markdown source.

htmlToMarkdown

Converts editor HTML, or an element, to Markdown. Browser only.

PropTypeDefaultDescription
inputRequiredstring | HTMLElement–HTML string or element.

sanitizeHtml

Keeps only the tags the editor understands and drops every attribute except safe link hrefs (http, https, mailto, tel, relative, hash). Bold and italic spans from other editors become marks. Browser only.

PropTypeDefaultDescription
htmlRequiredstring–Untrusted HTML.

normalizeUrl

Adds a scheme to bare input: example.com/docs becomes https://example.com/docs and an email becomes a mailto link.

PropTypeDefaultDescription
inputRequiredstring–What the person typed.
Cmd/Ctrl+B
Toggles bold.
Cmd/Ctrl+I
Toggles italic.
Cmd/Ctrl+Shift+X
Toggles strikethrough.
Cmd/Ctrl+E
Toggles inline code within one block.
Cmd/Ctrl+K
Opens the link field for the selection or the link under the caret.
Cmd/Ctrl+ZorCmd/Ctrl+Shift+ZorCmd/Ctrl+Y
Undo and redo in the editor's own history. Underline (Cmd/Ctrl+U) is blocked.
/
After a space or at line start, opens the block menu. Type to filter; Arrow Up and Down move, Enter or Tab chooses, Escape closes.
TaborShift+Tab
Indents or outdents a list item; in a code block Tab inserts two spaces.
EnterorShift+Enter
New block or a line break. Enter on an empty list item or quote leaves it; two Enters at the end of a code block exit it.
Backspace
At the start of a heading, quote, code block, or list item, turns it back into a paragraph or outdents it; after a divider, removes the divider.
Alt+F10
Moves focus into the floating toolbar while it is open. Arrow Left and Right move between buttons; Escape returns to the text.
  • The editing surface has role textbox with aria-multiline and aria-label, and aria-readonly when read-only.
  • The slash menu is a listbox wired to the textbox with aria-controls and aria-activedescendant.
  • The floating toolbar has role toolbar with toggle buttons using aria-pressed; titles show shortcuts.
  • A polite live region announces Formatted, Link added, Undone, and similar results.
  • Toolbar buttons prevent mousedown so the text selection survives clicks.
  • The toolbar glides with the selection and its surface springs between the formatting face and the link face, which slide and blur across each other.
  • The slash menu pops from the slash, its highlight glides between rows, and its height springs as results filter.
  • Reduced motion jumps the toolbar position and size, crossfades the faces, and fades menus without scale or offset.
  • The root is an inline-size container; under 330px wide the toolbar hides its heading and quote buttons and keeps the inline marks.
  • The toolbar and slash menu flip above or below based on the nearest scrolling or clipping ancestor, and the toolbar is clamped inside the editor width.
  • Enter, Backspace, and undo go through beforeinput, so mobile keyboards and IME composition behave the same as hardware keys.
  • Each selection change is batched to one requestAnimationFrame; each edit clones the DOM once to produce HTML, Markdown, and text for onChange.
  • Undo keeps up to 300 HTML snapshots, merging typing within one second; very long documents make each snapshot and the per-edit clone more costly.
  • No editor framework: only motion and lucide-react icons ship with it.

Notes for AI

Give your coding assistant the Markdown reference instead of screenshots.

  • Choose it for comments, notes, posts, and descriptions that need headings, lists, quotes, code, and links. Use textarea for plain text and mention-input when the only structure is @ mentions.
  • Store onChange markdown or html; both are clean. The value prop is HTML and only rewrites the DOM when it differs from the last emitted HTML, so feeding onChange html back is safe.
  • Supported blocks are p, h1 to h3, ul, ol, blockquote, pre, and hr; inline marks are strong, em, s, code, and a. Images, tables, and underline are not supported.
  • Paste runs through sanitizeHtml, and markdown-looking plain text is converted, so content from docs and other editors stays tidy.
  • Build your own undo buttons with onHistoryChange and ref.undo and ref.redo.

The full library index for assistants is at /llms.txt.