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.
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@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
| Prop | Type | Default | |
|---|---|---|---|
features | ContentRegistry | readonly ContentFeature[] | required | The 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) => void | required | Called with the whole next document on every edit, keystrokes included. |
value | ContentValue | null | required | The document being edited, plain serialisable data; null is empty. |
autoFocus | boolean | — | |
className | string | — | |
count | boolean | false | Shows the character and word count under the document. |
generateNodeId | (() => string) | — | Node-id factory, injectable for deterministic tests. |
labels | ContentEditorLabelsInput | — | |
mode | ContentEditorMode | — | block 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. |
placeholder | string | — | |
readOnly | boolean | — | |
stickyToolbar | boolean | false | Keeps the toolbar row visible while a long document scrolls under it. |
theme | ContentEditorThemeInput | — | |
toolbar | boolean | true | false 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
- Blocks: text, marks, headings, lists, quotes, dividers, tables, callouts.
- Media and embeds: images with upload, video, files, YouTube, X.
- Toolbar and slash menu: the chrome, and how to compose it.
- Custom features: a node kind of your own, in one definition.
- Labels and theming: every string and every size the host owns.
- Markdown: the round trip, and what it loses.
- Server-side rendering: the reader entry,
contentToHtmlandContentRenderer.