oks-ui

How to use

TextEditor

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.

import { TextEditor, type Block } from "oks-ui";

Controlled content

Pass value/onChange to own the block array yourself; omit onChange and TextEditor manages its own internal state instead. Either way, value is what seeds the initial content on mount.

function Notes() {
  const [blocks, setBlocks] = useState<Block[]>([
    { id: "1", type: "paragraph", props: {}, content: [], children: [] },
  ]);
  return <TextEditor value={blocks} onChange={setBlocks} />;
}

Inserting blocks with the slash menu

Type / at the start of an empty block to open the slash menu — all 14 block types by default, or a filtered subset via allowedBlocks if a given editor shouldn't offer everything (a comments field that only needs paragraph/bulletList, say).

<TextEditor
  value={blocks}
  onChange={setBlocks}
  allowedBlocks={["paragraph", "heading", "bulletList", "numberedList"]}
/>

Image uploads

TextEditor doesn't own file storage — pass onImageUpload and ImageBlock's drop zone calls it as a delegating callback, displaying whatever URL it resolves to. 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) => {
    const url = await uploadToStorage(file);
    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

blockToHtml (exported alongside TextEditor) serializes a single block to an HTML string — call it per block to build a full document, for anywhere you need static HTML output rather than the live editable component (an email, a printed view, a public read-only page).

import { blockToHtml } from "oks-ui";

const html = blocks.map(blockToHtml).join("");

Accessibility

  • The editor root has role="document" with an aria-label derived from placeholder.
  • The slash menu uses the standard combobox-with-listbox-popup ARIA pattern (role="combobox"/role="listbox"/role="option", aria-activedescendant) — filtering keeps real DOM focus in the filter input, matching typical combobox behavior.
  • Per-block controls (move up/down, convert, delete) are real, keyboard-focusable buttons with aria-labels — reachable and visible via keyboard focus, not just mouse hover.
  • Formatting shortcuts (Ctrl/Cmd+B/I/U) are explicitly wired rather than relying on inconsistent native contentEditable browser defaults.

Props reference

All available props for TextEditor. See the full component page for an interactive playground.

Props

value

Block[]

Default: []

The current block array. Uncontrolled internally (updates flow through onChange), but value seeds the initial content.

placeholder

string

Default: "Type / for blocks..."

Aria-label for the editor root and the empty-paragraph placeholder text.

Best practices

  • Restrict allowedBlocks whenever an editor instance genuinely doesn't need the full 14 block types — a shorter slash menu is easier to scan, and it keeps the drag-handle's convert-cycle button from cycling through irrelevant types.
  • 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).
View full API reference for TextEditor