# Rich Text Editor **Category**: react **URL**: https://ui.darkcode.dev/en/docs/react/components/rich-text-editor **Source**: https://raw.githubusercontent.com/DarkCode-Developers/darkcode-ui/refs/heads/main/apps/docs/content/docs/en/react/components/(forms)/rich-text-editor.mdx > A composable Lexical editor with toolbar controls, selection and empty-line menus, slash commands, link popover, JSON value updates, and character count. *** ## Import ```tsx import { RichTextEditor } from '@darkcode-ui/react'; ``` DarkCode UI builds the Rich Text Editor on **[Lexical](https://lexical.dev)**. The value model is JSON-first: `value`/`defaultValue` are Lexical `SerializedEditorState` documents, and `onValueChange` also reports HTML, plain text, empty state, and character/word counts. ## Anatomy Compose the editor from its parts with dot notation. The root owns the Lexical editor instance and shares it with every part through context. ```tsx ``` ### Usage ```tsx "use client"; import {RichTextEditor} from "@darkcode-ui/react"; import {EditorBubbleMenu, EditorToolbar} from "./toolbar"; export function Default() { return (
); } ``` ### Controlled Pass `value` and `onValueChange` to control the document. The handler receives the JSON document plus a details object (`{ json, html, text, isEmpty, characterCount, wordCount }`). ```tsx "use client"; import type {RichTextEditorChangeDetails, RichTextEditorValue} from "@darkcode-ui/react"; import {RichTextEditor} from "@darkcode-ui/react"; import {useState} from "react"; import {EditorBubbleMenu, EditorToolbar} from "./toolbar"; export function Controlled() { const [value, setValue] = useState(undefined); const [details, setDetails] = useState(null); return (
{ setValue(next); setDetails(nextDetails); }} >
{details?.characterCount ?? 0}{" "} characters · {details?.wordCount ?? 0}{" "} words ·{" "} {String(details?.isEmpty ?? true)}{" "} empty
); } ``` ### Character Count Add `maxLength` and render `RichTextEditor.CharacterCount` in the footer. It exposes a `data-over-limit` attribute when the limit is exceeded. ```tsx "use client"; import {RichTextEditor} from "@darkcode-ui/react"; import {EditorBubbleMenu, EditorToolbar} from "./toolbar"; export function CharacterCount() { return (
Markdown-style formatting supported
); } ``` ### Placeholder ```tsx "use client"; import {RichTextEditor} from "@darkcode-ui/react"; import {EditorToolbar} from "./toolbar"; export function Placeholder() { return (
); } ``` ### Disabled And Read Only `isDisabled` dims the editor and blocks interaction; `isReadOnly` keeps the content focusable but not editable. ```tsx "use client"; import {RichTextEditor} from "@darkcode-ui/react"; import {$createQuoteNode} from "@lexical/rich-text"; import {$createParagraphNode, $createTextNode, $getRoot} from "lexical"; import {EditorToolbar} from "./toolbar"; /** Seeds the editor with a short document. Runs inside `editor.update`. */ function seedDocument() { const root = $getRoot(); if (root.getFirstChild() !== null) { return; } const intro = $createParagraphNode(); const strong = $createTextNode("DarkCode UI"); strong.toggleFormat("bold"); intro.append( $createTextNode("The "), strong, $createTextNode(" rich text editor keeps its content visible while editing is turned off."), ); const quote = $createQuoteNode(); quote.append($createTextNode("Write once, render anywhere.")); root.append(intro, quote); } export function Disabled() { return (
Read only
Disabled
); } ``` ### Custom Composition Rearrange the parts freely — drop the toolbar, keep only the bubble menu, add a footer, and so on. ```tsx "use client"; import {RichTextEditor} from "@darkcode-ui/react"; export function CustomComposition() { return (
{({words}) => {words} words}
); } ``` ### Extensible Commands Use `CommandButton`, `FloatingMenu`, `SuggestionMenu`, and the editor hooks to wire custom Lexical commands without waiting for a new built-in prop. ```tsx "use client"; import { IconCalendar, IconCode, IconCodeBlock, IconHeading1, IconHeading2, IconHeading3, IconListUnordered, IconQuote, RichTextEditor, filterRichTextEditorSuggestionItems, } from "@darkcode-ui/react"; import {$createCodeNode} from "@lexical/code"; import {INSERT_UNORDERED_LIST_COMMAND} from "@lexical/list"; import {$createHeadingNode, $createQuoteNode} from "@lexical/rich-text"; import {$setBlocksType} from "@lexical/selection"; import {$getSelection, $isRangeSelection} from "lexical"; const slashItems = ({query}: {query: string}) => filterRichTextEditorSuggestionItems( [ { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h1")); } }), description: "Big section heading", icon: , keywords: ["title", "h1"], title: "Heading 1", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h2")); } }), description: "Medium section heading", icon: , keywords: ["subtitle", "h2"], title: "Heading 2", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h3")); } }), description: "Small section heading", icon: , keywords: ["h3"], title: "Heading 3", }, { command: ({editor}) => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), description: "Create a simple list", icon: , keywords: ["unordered", "ul"], title: "Bulleted list", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createQuoteNode()); } }), description: "Capture a quote", icon: , keywords: ["blockquote"], title: "Quote", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createCodeNode()); } }), description: "Insert a code block", icon: , keywords: ["pre", "snippet"], title: "Code block", }, ], query, ); export function Extensible() { return (
editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { selection.insertText( new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric", }), ); } }) } > editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h2")); } }) } > editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createCodeNode()); } }) } >
); } ``` ```tsx import { RichTextEditor, filterRichTextEditorSuggestionItems } from '@darkcode-ui/react'; import { $createHeadingNode } from '@lexical/rich-text'; import { $setBlocksType } from '@lexical/selection'; import { $getSelection, $isRangeSelection } from 'lexical'; const slashItems = ({ query }) => filterRichTextEditorSuggestionItems( [ { title: 'Heading 1', keywords: ['title', 'h1'], command: ({ editor }) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode('h1')); } }), }, ], query, ); editor.update(() => { $getSelection()?.insertText('27 May 2026'); }) } /> ; ``` Because DarkCode UI uses Lexical (not Tiptap), suggestion-item commands receive `{ editor }` and the trigger query text is removed for you before the command runs. ### Notion-style block editing Drop in `RichTextEditor.DraggableBlocks` for a Notion-like gutter — hover a line to reveal a drag handle (reorder whole blocks) and a "+" button (opens the slash menu). The editor also ships checklist items (the `checkList` command), a callout block, and a horizontal-rule divider, so a slash menu can offer the full block palette. ```tsx "use client"; import { $createCalloutNode, INSERT_CHECK_LIST_COMMAND, INSERT_HORIZONTAL_RULE_COMMAND, IconCallout, IconChecklist, IconCodeBlock, IconDivider, IconHeading1, IconHeading2, IconListUnordered, IconQuote, RichTextEditor, filterRichTextEditorSuggestionItems, } from "@darkcode-ui/react"; import {$createCodeNode} from "@lexical/code"; import {INSERT_UNORDERED_LIST_COMMAND} from "@lexical/list"; import {$createHeadingNode, $createQuoteNode} from "@lexical/rich-text"; import {$setBlocksType} from "@lexical/selection"; import {$getSelection, $isRangeSelection} from "lexical"; const slashItems = ({query}: {query: string}) => filterRichTextEditorSuggestionItems( [ { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h1")); } }), description: "Big section heading", icon: , keywords: ["title", "h1"], title: "Heading 1", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode("h2")); } }), description: "Medium section heading", icon: , keywords: ["subtitle", "h2"], title: "Heading 2", }, { command: ({editor}) => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), description: "Create a simple list", icon: , keywords: ["unordered", "ul"], title: "Bulleted list", }, { command: ({editor}) => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), description: "Track tasks with a to-do list", icon: , keywords: ["todo", "task", "check"], title: "Checklist", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createQuoteNode()); } }), description: "Capture a quote", icon: , keywords: ["blockquote"], title: "Quote", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createCalloutNode()); } }), description: "Make text stand out", icon: , keywords: ["note", "info", "highlight"], title: "Callout", }, { command: ({editor}) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined), description: "Visually separate blocks", icon: , keywords: ["hr", "rule", "line"], title: "Divider", }, { command: ({editor}) => editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createCodeNode()); } }), description: "Insert a code block", icon: , keywords: ["pre", "snippet"], title: "Code block", }, ], query, ); export function Notion() { return (
); } ``` ## CSS Classes ### Base Classes - `.rich-text-editor` – Root wrapper - `.rich-text-editor__shell` – Editor surface ### Element Classes - `.rich-text-editor__toolbar` – Toolbar container - `.rich-text-editor__toolbar-group` – Toolbar group - `.rich-text-editor__toolbar-button` – Toolbar button slot - `.rich-text-editor__toolbar-separator` – Toolbar separator - `.rich-text-editor__content` – Lexical content host - `.rich-text-editor__prosemirror` – Editable element - `.rich-text-editor__bubble-menu` – Floating selection toolbar - `.rich-text-editor__bubble-menu-toolbar` – Toolbar inside the floating selection menu - `.rich-text-editor__floating-menu` – Floating empty-line toolbar - `.rich-text-editor__floating-menu-toolbar` – Toolbar inside the floating empty-line menu - `.rich-text-editor__suggestion-menu` – Triggered suggestion menu - `.rich-text-editor__suggestion-menu-item` – Suggestion menu item - `.rich-text-editor__footer` – Footer row - `.rich-text-editor__character-count` – Character and word count - `.rich-text-editor__link-popover` – Link popover surface - `.rich-text-editor__link-input` – Link URL input - `.rich-text-editor__block-menu` – Block gutter (drag handle + add button) - `.rich-text-editor__drag-handle` – Drag-to-reorder handle - `.rich-text-editor__add-block` – "+" add-block button - `.rich-text-editor__drag-target-line` – Drop indicator line - `.rich-text-editor__callout` – Callout block - `.rich-text-editor__divider` – Horizontal-rule divider - `.rich-text-editor__listitem-checked` / `.rich-text-editor__listitem-unchecked` – Checklist items ### States - **Disabled**: `[data-disabled="true"]` on `.rich-text-editor` - **Read only**: `[data-readonly="true"]` on `.rich-text-editor` - **Over limit**: `[data-over-limit="true"]` on `.rich-text-editor__character-count` - **Active command**: `[data-active="true"]` on toolbar buttons ## API Reference ### RichTextEditor The root provider. It owns the Lexical editor instance and emits JSON-first value updates. | Prop | Type | Default | Description | | --------------- | -------------------------------------- | -------------------- | ------------------------------------------------------------------------------- | | `value` | `SerializedEditorState` | - | Controlled Lexical JSON document | | `defaultValue` | `SerializedEditorState` | - | Initial uncontrolled JSON document | | `onValueChange` | `(value, details) => void` | - | Called with JSON plus HTML, text, empty state, character count, and word count | | `placeholder` | `string` | `"Start writing..."` | Placeholder text for empty content | | `maxLength` | `number` | - | CharacterCount limit | | `isDisabled` | `boolean` | `false` | Disables editing and controls | | `isReadOnly` | `boolean` | `false` | Keeps content focusable but not editable | | `extensions` | `Klass[]` | - | Additional Lexical nodes appended after the defaults | | `editorOptions` | `Partial` | - | Advanced Lexical composer options merged after defaults | The `details` object passed to `onValueChange` is `{ json, html, text, isEmpty, characterCount, wordCount }`. ### RichTextEditor.ToggleButton Runs a formatting command and subscribes to active/disabled editor state. | Prop | Type | Description | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | `command` | `"bold" \| "italic" \| "underline" \| "strike" \| "code" \| "blockquote" \| "bulletList" \| "orderedList" \| "checkList" \| "codeBlock" \| "heading-1" \| "heading-2" \| "heading-3"` | Formatting command | | `tooltip` | `ReactNode` | Optional tooltip content | ### RichTextEditor.ActionButton Runs editor actions. | Prop | Type | Description | | --------- | --------------------------------------------------------- | ------------------------ | | `action` | `"undo" \| "redo" \| "clearFormatting" \| "clearContent"` | Editor action | | `tooltip` | `ReactNode` | Optional tooltip content | ### RichTextEditor.CommandButton Runs any custom Lexical command. | Prop | Type | Description | | ------------ | ------------------------------------------ | ------------------------------------------------ | | `onCommand` | `(editor) => void \| boolean` | Command to run with the current editor | | `isActive` | `boolean \| ((editor) => boolean)` | Active state used for styling and `aria-pressed` | | `isDisabled` | `boolean \| ((editor) => boolean)` | Command-specific disabled state | | `tooltip` | `ReactNode` | Optional tooltip content | ### RichTextEditor.LinkPopover Compound popover for applying and removing links through Lexical's link commands. Subcomponents: `Trigger`, `Content`, `Input`, `Actions`, `ApplyButton`, and `UnsetButton`. ### RichTextEditor.Content Renders the Lexical `ContentEditable` and placeholder for the current editor instance. ### RichTextEditor.BubbleMenu A floating selection toolbar. By default it appears for non-empty text selections. | Prop | Type | Default | Description | | -------------- | -------------- | ------- | -------------------------------------------------- | | `toolbarProps` | `ToolbarProps` | - | Props forwarded to the internal selection toolbar | ### RichTextEditor.FloatingMenu A floating empty-line toolbar. By default it appears on an empty editable block while the editor is focused. | Prop | Type | Default | Description | | -------------- | -------------- | ------- | -------------------------------------------------- | | `toolbarProps` | `ToolbarProps` | - | Props forwarded to the internal insertion toolbar | ### RichTextEditor.DraggableBlocks A Notion-style block gutter. Mount it inside `Shell`; it adds a hover drag handle (reorder whole blocks) and an optional "+" add-block button. | Prop | Type | Default | Description | | ---------------- | --------- | ------- | ------------------------------------------------------------ | | `showAddButton` | `boolean` | `true` | Show the "+" button that opens the slash menu | | `addTriggerChar` | `string` | `"/"` | Character inserted by the add button (empty string to skip) | ### RichTextEditor.SuggestionMenu Registers a Lexical typeahead plugin for slash commands, mentions, and other triggered menus. Items with `{ title, description, icon, keywords, command }` render in the default menu. | Prop | Type | Default | Description | | ------------- | ----------------------------------------------------------------------------- | ------- | ----------------------------------------- | | `char` | `string` | `"/"` | Trigger character | | `items` | `({ query, editor }) => Item[] \| Promise` | - | Suggestion item loader | | `children` | `(props) => ReactNode` | - | Custom renderer for complete menu control | | `onSelect` | `(props) => void` | - | Selection handler for custom item shapes | | `allowSpaces` | `boolean` | `false` | Allows spaces in the query | | `maxHeight` | `number` | `384` | Max menu height in pixels | ### RichTextEditor.CharacterCount Displays the editor's character and word counts. | Prop | Type | Default | Description | | ----------- | ----------------------------------- | ------- | --------------------------------------- | | `showWords` | `boolean` | `false` | Appends word count to the default label | | `children` | `ReactNode \| (stats) => ReactNode` | - | Custom count rendering | ### Hooks | Hook | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------------ | | `useRichTextEditor()` | Returns `{ editor, isDisabled, isReadOnly, maxLength }` from the nearest editor | | `useRichTextEditorState(selector, equalityFn)` | Subscribes to selected Lexical editor state without re-rendering for unrelated edits |