Quick start

A rich content editor that lives in your app, from one feature registry.

A rich content editor that lives in your app, from one feature registry.

A blog post, a help page, a legal notice: the long-form text a product carries is usually edited somewhere else and pasted in. This module puts the editing surface inside your own app, built on Plate and the kit's own primitives. Headings, marks, links, lists, quotes, tables, callouts, images with upload, and embeds each come from one feature definition, and the toolbar, the slash menu, the floating toolbar, the reader-side renderer and the HTML serializer are all derived from that list.

Writing inside your own app

Rich text with bold, italic and a  link . Type / for a block, or select text for the floating toolbar.
Every block on this page is one feature definition.
  • Lists, with Tab to nest
  • Tables, images, embeds
And a quote, for when someone else said it better.
273 characters · 52 words

This is the real component. Select text for the floating toolbar, type / for a block, drop an image on the canvas.

Install

bun add @voila.dev/ui platejs @platejs/basic-nodes @platejs/link @platejs/list @platejs/indent @platejs/table @platejs/slash-command @platejs/combobox @platejs/markdown remark-gfm
globals.css
@import "@voila.dev/ui/styles/sources.css";
@import "@voila.dev/ui/styles/content-editor.css";

Peers: react@19, react-dom@19, tailwindcss@4. The Plate packages are optional peers of the kit: a host that never imports content-editor never installs them.

The 3-minute win

The editor is controlled: you hold the document, it reports changes. A document is a Slate tree, plain JSON your database stores as it is.

import {
	type ContentValue,
	ContentEditorField,
	createContentFeatures,
} from "@voila.dev/ui/content-editor";
import { useState } from "react";
 
// Built once, outside the component: the editor's configuration, not its state.
const FEATURES = createContentFeatures();
 
export function PostBodyEditor() {
	const [body, setBody] = useState<ContentValue | null>(null);
	return (
		<ContentEditorField
			features={FEATURES}
			value={body}
			onChange={setBody}
			onUploadImage={async (file) => {
				// Upload to your own storage, resolve with the url.
				const { url } = await uploadImage(file);
				return { url };
			}}
		/>
	);
}

Omit onUploadImage and every image affordance disappears. Pass toolbar={false} for a chrome-less field, mode="single-line" for one paragraph, readOnly to show the document without editing it.

Arranging it differently

ContentEditorField is the parts put together the usual way. Compose them yourself for another arrangement; every part renders a sensible default with no children.

<ContentEditor.Root features={FEATURES} value={body} onChange={setBody}>
	<ContentEditor.Layout stickyToolbar>
		<ContentEditor.Toolbar />
		<ContentEditor.Canvas />
		<ContentEditor.CharacterCount />
	</ContentEditor.Layout>
	<ContentEditor.FloatingToolbar />
</ContentEditor.Root>

Mental model

A feature is one object: its Plate plugins, its canvas element, its toolbar and slash items, and its reader half, which renders the node to React and to an HTML string. createContentFeatures() returns the built-in ones; add your own, drop what you do not want, reorder them. The reader halves are exported on their own from @voila.dev/ui/content-editor/reader, so a server build that only renders stored content never loads Plate.

API

PropTypeDefault
featuresContentRegistry | readonly ContentFeature[]requiredThe features this editor offers. createContentFeatures returns the ones this package ships; add your own, drop what you do not want, reorder them. Either the list or a registry built from it.
onChange(value: ContentValue) => voidrequiredCalled with the whole next document on every edit, keystrokes included.
valueContentValue | nullrequiredThe document being edited, plain serialisable data; null is empty.
autoFocusboolean
classNamestring
countbooleanfalseShows the character and word count under the document.
generateNodeId(() => string)Node-id factory, injectable for deterministic tests.
labelsContentEditorLabelsInput
modeContentEditorModeblock is the full editor. inline drops every block feature (marks and links only). single-line is inline with one paragraph and Enter as a submit rather than a break.
onBlur(() => void)Reaches the editable element, for a form's blur and focus.
onUploadImage((file: File) => Promise<ContentUploadedImage>)Delegated image upload: receives the picked file, resolves with its url. Omit to hide every image affordance.
placeholderstring
readOnlyboolean
stickyToolbarbooleanfalseKeeps the toolbar row visible while a long document scrolls under it.
themeContentEditorThemeInput
toolbarbooleantruefalse for a chrome-less field: the floating toolbar and the slash menu remain.

Plus the DOM props of the element it renders. Source: content-editor/components/content-editor-field.tsx.

Page map