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 @hoplallc/ui platejs @platejs/basic-nodes @platejs/link @platejs/list @platejs/indent @platejs/table @platejs/resizable @platejs/layout @platejs/slash-command @platejs/combobox @platejs/markdown remark-gfm@import "@hoplallc/ui/styles/sources.css";
@import "@hoplallc/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 "@hoplallc/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 @hoplallc/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. |
appearance | ContentEditorAppearance | — | How the canvas looks. document is a page of prose; plain is a
mail being written, as in Gmail; email is the campaign card, the
theme's width and colours around the text. |
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. |
onDropFiles | ((files: readonly File[]) => void) | — | The files dropped or pasted that no feature takes: every file but the
images onUploadImage puts inline, or every file without it. Omit and
they are ignored. |
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 | — | Colours, font, preview locale and sizes of the canvas. |
toolbar | boolean | true | false for a chrome-less field: the floating toolbar and the slash menu remain. |
variables | readonly ContentVariable[] | — | What typing {{ offers, with the variable feature in the list. |
Plus the DOM props of the element it renders. Source: content-editor/components/content-editor-field.tsx.
Page map
- Presets: a document, a mail, a campaign email.
- Blocks: text, marks, headings, lists, quotes, dividers, tables, callouts, code blocks.
- Media and embeds: images with upload, video, files, YouTube, X.
- Files and drop: dropped and pasted files, and the attachments they become.
- Keyboard: the keys, the same as in Gmail and Notion.
- Inspector and fields: the settings of the selected element, beside the canvas.
- Defining a feature: a block from a list of fields and a view.
- Variables: a recipient's name, typed as
{{. - Columns: one to four columns side by side.
- Email blocks: every block of the email preset, one page each.
- Rendering an email: the email you send, from one exhaustive
switch. - 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.