TextEditor component
TextEditor is a block-based rich text editor in the Notion mold — content is a flat array of typed blocks (paragraphs, headings, lists, tables, media embeds, and more), each independently editable, reorderable, and convertible to a different block type. It's built on native contentEditable, not a third-party rich-text engine, so there's nothing extra to bundle.
Try it
The real editor, not a screenshot. Type, press /, mention someone, add a CSS class to a block, flip to Preview, and watch the HTML, Markdown and JSON change below it.
.is-highlight { background: color-mix(in srgb, orange 14%, transparent); border-radius: 6px; padding-inline: 8px; }
.is-large { font-size: 1.2em; font-weight: 600; }
.is-muted { opacity: 0.6; }Recipes
Copy-paste patterns for the ways TextEditor actually gets used — each one a real, self-contained example.
Block editor with Edit / Preview
A controlled block editor: value is an array of Block objects and onChange gives you the updated tree. Type / on an empty line to add a heading, list, table and more; select text to format it. showPreview adds an Edit / Preview switch, and Preview shows the page as a reader sees it, at desktop, tablet or phone width.
const [blocks, setBlocks] = useState<Block[]>([
{ id: "1", type: "paragraph", props: {}, content: [], children: [] },
]);
<TextEditor value={blocks} onChange={setBlocks} showPreview />Custom CSS classes on a block
blockClasses puts a settings button beside every block with an Additional CSS class(es) field, like the WordPress block editor. classOptions adds ready-made classes as toggles. The classes are kept in block.props.className, shown on the block while you edit, and written to the HTML export, so your own stylesheet gives them a look.
const styles = [
{ label: "Highlight", className: "is-highlight" },
{ label: "Large", className: "is-large" },
];
<TextEditor
value={blocks}
onChange={setBlocks}
blockClasses
classOptions={styles}
/>
/* your stylesheet */
.is-highlight { background: #fff3cd; border-radius: 6px; }
.is-large { font-size: 1.2em; font-weight: 600; }Mentions and emoji
Pass onMentionSearch and typing @ opens a list under the caret, filled by your function (it may return a promise and gets an abort signal). emoji turns on the : picker. A mention is stored with its id, so you keep control of the names.
<TextEditor
value={blocks}
onChange={setBlocks}
emoji
onMentionSearch={(query) =>
people.filter((p) => p.label.toLowerCase().includes(query.toLowerCase()))
}
/>Character limit and count
maxLength caps the characters of the whole document: typing, dropping and pasting stop at the limit. showCount shows the characters and words under the editor.
<TextEditor value={blocks} onChange={setBlocks} maxLength={280} showCount />A block of your own
registerBlock adds a block type of your own (a poll, a product card). It joins the slash menu, undo, drag and copy and paste, and is stored as { type, props } in the document. Type / and choose Poll.
registerBlock({
type: "poll",
label: "Poll",
defaultProps: { votes: 0 },
render: ({ props, setProps, readOnly }) => (
<button disabled={readOnly} onClick={() => setProps({ votes: Number(props.votes) + 1 })}>
Votes: {String(props.votes)}
</button>
),
});Limit block types
allowedBlocks trims the slash menu to a subset — a comment field that only needs paragraphs and lists, say, instead of every block type.
<TextEditor
value={blocks}
onChange={setBlocks}
allowedBlocks={["paragraph", "heading", "bulletList", "numberedList"]}
/>Read-only render
readOnly renders the same block content as static, non-editable output — for displaying saved documents with the editor's own styling.
<TextEditor value={savedBlocks} readOnly />Controlled content
Pass value/onChange to own the block array yourself; omit them and TextEditor manages its own state. When value changes from outside (a saved document loads, a form resets), the editor shows the new content. blocksFromHtml and blocksFromMarkdown turn HTML or Markdown into blocks for value.
const Notes = () => {
const [blocks, setBlocks] = useState<Block[]>([
{ id: "1", type: "paragraph", props: {}, content: [], children: [] },
]);
return <TextEditor value={blocks} onChange={setBlocks} />;
};As a form field
TextEditor takes label, description, error and isDisabled like the other fields, and inside an oks-ui Form a name stores the blocks under that key, with validation for the rules. Give it a ref to focus it, clear it, insert a block, read getStats() (characters and words) or serialise with toHtml() and toMarkdown().
<Form onSubmit={save}>
<TextEditor label="Notes" name="notes" validation={{ rules: { required: true } }} />
</Form>Preview and custom CSS classes
showPreview adds an Edit / Preview switch to the toolbar. Preview shows the document the way a reader sees it (read-only, no toolbar or block controls) in a frame you can set to desktop, tablet or phone width, and Edit returns to the same text and undo history.
blockClasses adds a settings button beside every block with an "Additional CSS class(es)" field, the same idea as the WordPress block editor. classOptions adds ready-made classes as toggles. The classes are kept in block.props.className, shown on the block while you edit, and written to the exported HTML (blocksToHtml, blockToHtml) as a class attribute, so your own stylesheet gives them a look. Only plain class names are kept: letters, digits, - and _, up to 8 per block.
const styles = [
{ label: "Highlight", className: "is-highlight" },
{ label: "Large", className: "is-large" },
];
<TextEditor
value={blocks}
onChange={setBlocks}
showPreview
blockClasses
classOptions={styles}
/>
/* your stylesheet */
.is-highlight { background: #fff3cd; border-radius: 6px; }Character limit, mentions, emoji and find
maxLength limits the characters of the whole document and showCount shows "128 / 500 characters"; text that does not fit is cut off, and a longer saved value is kept and marked over the limit rather than cut. Passing onMentionSearch turns on @ mentions (people are stored with their id and label, and Backspace removes one as a unit), and emoji turns on : emoji. Ctrl/Cmd+F opens find and replace; Replace all is one undo step. Set findReplace={false} to leave the shortcut to the browser.
<TextEditor
value={blocks}
onChange={setBlocks}
maxLength={500}
showCount
emoji
onMentionSearch={(query, { signal }) =>
fetch(`/api/people?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json())
}
/>Your own blocks
registerBlock adds a block of your own (a poll, a product card) once, at start-up in a client module. It gets a type, a label for the slash menu, defaultProps and a render function that receives props and setProps. It is stored as { type, props } in the document, so it survives undo, drag, copy and paste, and allowedBlocks and labels.blockNames treat it like a built-in block.
import { registerBlock } from "oks-ui/text-editor";
registerBlock({
type: "poll",
label: "Poll",
defaultProps: { votes: [0, 0] },
render: ({ props, setProps }) => <PollBox votes={props.votes} onVote={(i) => setProps({ votes: bump(props.votes, i) })} />,
});Inserting blocks with the slash menu
Type / on an empty line to open the slash menu and search it — by name or by common alternatives like "todo", "h2" or "hr". It offers all 17 block types (Heading as Heading 1–6) by default, or a subset via allowedBlocks when an editor shouldn't offer everything (a comments field that only needs paragraphs and lists, say). The toolbar's block-type dropdown and each line's "Turn into" menu change an existing line's type, including a heading's level.
<TextEditor
value={blocks}
onChange={setBlocks}
allowedBlocks={["paragraph", "heading", "bulletList", "numberedList"]}
/>Keyboard and Markdown shortcuts
Start a line with # to ###### (then Space) for Heading 1–6, - or * for a bullet list, 1. for a numbered list, [] for a to-do, > for a quote, --- for a divider, or three backticks for a code block.
Enter splits the line at the caret; Enter on an empty list item leaves the list. Backspace at the start of a line joins it to the one above (headings, lists and quotes turn back into text first). Tab and Shift+Tab indent list items. Ctrl/⌘+B, I and U format; Ctrl/⌘+Z and Ctrl/⌘+Shift+Z undo and redo. In a code block, Enter adds a line and Ctrl/⌘+Enter leaves it.
Drag from one line into another — or press Ctrl/⌘+A twice — to select whole blocks, then copy, cut or delete them. Select text to get the formatting bar (bold, italic, code, colour, highlight, link).
Copy and paste
Pasting from a web page, Google Docs or Word keeps the structure — headings, lists, to-dos, quotes, code, tables, images — and basic formatting and links, but not fonts or colours, so pasted text matches the editor's theme. Plain text is read as light Markdown. Pasting in the middle of a line splits it around the pasted content, and one undo removes the whole paste.
Image uploads
TextEditor doesn't own file storage — pass onImageUpload (or onFileUpload, onVideoUpload) and the block calls it with the file and { onProgress, signal }: call onProgress(0 to 1) to fill a progress bar, and the signal aborts when the person cancels. The block shows whatever URL it resolves to, and a failed upload offers Retry. maxFileSize refuses big files first. Without onImageUpload, ImageBlock still accepts a pasted/typed image URL directly; only the drop zone itself needs the callback.
<TextEditor
value={blocks}
onChange={setBlocks}
onImageUpload={async (file, { onProgress, signal }) => {
const url = await uploadToStorage(file, { onProgress, signal });
return url;
}}
/>Read-only rendering
readOnly disables every block's editable region (text, table cells, code blocks) and hides the drag handle and per-block controls — for displaying already-saved content without any editing chrome at all.
<TextEditor value={savedBlocks} readOnly />Exporting to HTML
blocksToHtml turns a saved block array into one HTML document (consecutive list items share a list, headings keep their level) — for an email, a printed view or a public page. blocksToMarkdown does the same as Markdown, and blockToHtml converts a single block.
import { blocksToHtml, blocksToMarkdown } from "oks-ui/text-editor";
const html = blocksToHtml(blocks);
const markdown = blocksToMarkdown(blocks);Props
The full public API for TextEditor. Every prop is stable and intentionally typed.
Content
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Block[] | [] | The current block array. Changes from outside (loading a saved document, resetting a form) replace the content; typing flows out through onChange. |
| placeholder | string | "Type / to add a block" | Hint shown in an empty paragraph (the focused line, or a lone empty block). |
| label / description / error | ReactNode | — | Field label, help text and error (error also accepts true for the invalid look). The editor is a form field. |
| labels | Partial<TextEditorLabels> | English | Every piece of built-in wording (menus, hints, messages, block names) for translation or rewording. |
| showCount | boolean | false | Shows the character and word count under the editor. |
| classOptions | { label: string; className: string }[] | — | Ready-made classes offered as toggles in the block settings (needs blockClasses), like WordPress block styles. Define the array outside your component. |
value
Block[]
default: []
The current block array. Changes from outside (loading a saved document, resetting a form) replace the content; typing flows out through onChange.
placeholder
string
default: "Type / to add a block"
Hint shown in an empty paragraph (the focused line, or a lone empty block).
label / description / error
ReactNode
default: —
Field label, help text and error (error also accepts true for the invalid look). The editor is a form field.
labels
Partial<TextEditorLabels>
default: English
Every piece of built-in wording (menus, hints, messages, block names) for translation or rewording.
showCount
boolean
default: false
Shows the character and word count under the editor.
classOptions
{ label: string; className: string }[]
default: —
Ready-made classes offered as toggles in the block settings (needs blockClasses), like WordPress block styles. Define the array outside your component.
Behavior & states
| Prop | Type | Default | Description |
|---|---|---|---|
| allowedBlocks | BlockType[] | all 17 built-in types | Restricts the slash menu, the toolbar's block-type dropdown and each line's Turn into menu to these types. |
| readOnly | boolean | false | Disables every block's contentEditable region and hides the drag handle/per-block controls. |
| autoFocus | boolean | false | Focuses the first block on mount. |
| isDisabled | boolean | false | Dims the editor and ignores input. |
| maxLength | number | — | Character limit across the whole document. Typing, dropping and pasting stop at it. |
| showPreview | boolean | false | Adds an Edit / Preview switch. Preview shows the document read-only, as a reader sees it, at desktop, tablet or phone width; Edit returns to the same text. |
| blockClasses | boolean | false | Adds a settings button to every block with an Additional CSS class(es) field. The classes live in block.props.className, show on the block while editing, and are written to the exported HTML as a class attribute. |
| findReplace | boolean | true | Ctrl/Cmd+F opens find and replace. false leaves the shortcut to the browser. |
| onMentionSearch | (query, { signal }) => MentionItem[] | Promise<MentionItem[]> | — | Turns on @ mentions: called with what was typed, returns people to choose from. |
| emoji | boolean | false | Typing : and two letters lists matching emoji. |
| maxFileSize | number | — | Largest upload in bytes; bigger files are refused with a message. |
| onVideoUpload | (file, { onProgress, signal }) => Promise<string> | — | Uploads a video file; same contract as the image and file callbacks. |
| ref | Ref<TextEditorHandle> | — | focus(), clear(), insertBlock(), getBlocks(), setBlocks(), getStats(), undo(), redo(), toHtml(), toMarkdown(). |
allowedBlocks
BlockType[]
default: all 17 built-in types
Restricts the slash menu, the toolbar's block-type dropdown and each line's Turn into menu to these types.
readOnly
boolean
default: false
Disables every block's contentEditable region and hides the drag handle/per-block controls.
autoFocus
boolean
default: false
Focuses the first block on mount.
isDisabled
boolean
default: false
Dims the editor and ignores input.
maxLength
number
default: —
Character limit across the whole document. Typing, dropping and pasting stop at it.
showPreview
boolean
default: false
Adds an Edit / Preview switch. Preview shows the document read-only, as a reader sees it, at desktop, tablet or phone width; Edit returns to the same text.
blockClasses
boolean
default: false
Adds a settings button to every block with an Additional CSS class(es) field. The classes live in block.props.className, show on the block while editing, and are written to the exported HTML as a class attribute.
findReplace
boolean
default: true
Ctrl/Cmd+F opens find and replace. false leaves the shortcut to the browser.
onMentionSearch
(query, { signal }) => MentionItem[] | Promise<MentionItem[]>
default: —
Turns on @ mentions: called with what was typed, returns people to choose from.
emoji
boolean
default: false
Typing : and two letters lists matching emoji.
maxFileSize
number
default: —
Largest upload in bytes; bigger files are refused with a message.
onVideoUpload
(file, { onProgress, signal }) => Promise<string>
default: —
Uploads a video file; same contract as the image and file callbacks.
ref
Ref<TextEditorHandle>
default: —
focus(), clear(), insertBlock(), getBlocks(), setBlocks(), getStats(), undo(), redo(), toHtml(), toMarkdown().
Events
| Prop | Type | Default | Description |
|---|---|---|---|
| onChange | (blocks: Block[]) => void | — | Called with the full block array whenever content changes. |
| onImageUpload | (file, { onProgress, signal }) => Promise<string> | — | Delegating upload callback for ImageBlock's drop zone — oks-ui doesn't own file storage, it just calls this and displays the returned URL. Without it, ImageBlock accepts a pasted URL only. |
| onFileUpload | (file, { onProgress, signal }) => Promise<string> | — | Upload callback for the File block's drop zone (non-image attachments); returns the file's URL. |
| onUrlUnfurl | (url: string) => Promise<{ title?; description?; image? }> | — | Fetches a link's title, description and image for the Bookmark block. Without it, those stay editable text defaulting to the URL. |
onChange
(blocks: Block[]) => void
default: —
Called with the full block array whenever content changes.
onImageUpload
(file, { onProgress, signal }) => Promise<string>
default: —
Delegating upload callback for ImageBlock's drop zone — oks-ui doesn't own file storage, it just calls this and displays the returned URL. Without it, ImageBlock accepts a pasted URL only.
onFileUpload
(file, { onProgress, signal }) => Promise<string>
default: —
Upload callback for the File block's drop zone (non-image attachments); returns the file's URL.
onUrlUnfurl
(url: string) => Promise<{ title?; description?; image? }>
default: —
Fetches a link's title, description and image for the Bookmark block. Without it, those stay editable text defaulting to the URL.
Data
| Prop | Type | Default | Description |
|---|---|---|---|
| name / validation | string / FieldValidation | — | Inside a Form, name stores the blocks under that key and validation sets the rules. |
name / validation
string / FieldValidation
default: —
Inside a Form, name stores the blocks under that key and validation sets the rules.
Styling
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Class applied to the root element. |
className
string
default: —
Class applied to the root element.
Accessibility
- The editor root has role="document" labelled "Text editor"; each line is a labelled textbox, and the toolbar and selection format bar are role="toolbar" with aria-pressed on active formats.
- The slash menu uses the standard combobox-with-listbox-popup ARIA pattern (the combobox, listbox and option roles, with aria-activedescendant) — filtering keeps real DOM focus in the filter input, matching typical combobox behavior.
- Per-block controls (move up/down, turn into, delete) are real, keyboard-focusable buttons with aria-labels — reachable and visible via keyboard focus, not just mouse hover. Every menu and popover closes on Escape.
- Formatting shortcuts (Ctrl/Cmd+B/I/U) are explicitly wired rather than relying on inconsistent native contentEditable browser defaults.
Best practices
- Restrict allowedBlocks whenever an editor instance genuinely doesn't need every block type — shorter slash, Turn into and block-type menus are easier to scan.
- Always pass onImageUpload if the editor will realistically be used to add images — without it, ImageBlock silently falls back to URL-paste-only, which most users won't think to use.
- Debounce or otherwise rate-limit onChange side effects (autosave, syncing to a backend) rather than firing a network call on every keystroke — every character typed produces a new blocks array.
- Use readOnly for displaying finished content rather than manually stripping out the editing UI yourself — it's a single prop and covers every block type consistently, including ones you might otherwise miss (table cells, code blocks).