Skip to content

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.

Editors25 propsAccessibility notes

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.

blocks.css
.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

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

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

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

name / validation

string / FieldValidation

default: —

Inside a Form, name stores the blocks under that key and validation sets the rules.

Styling

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).