(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 (
);
}
```
### 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 |