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 @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
globals.css
@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

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.
appearanceContentEditorAppearance—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.
autoFocusboolean—
classNamestring—
countbooleanfalseShows the character and word count under the document.
generateNodeId(() => string)—Node-id factory, injectable for deterministic tests.
labelsContentEditorLabelsInput—
modeContentEditorMode—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.
placeholderstring—
readOnlyboolean—
stickyToolbarbooleanfalseKeeps the toolbar row visible while a long document scrolls under it.
themeContentEditorThemeInput—Colours, font, preview locale and sizes of the canvas.
toolbarbooleantruefalse for a chrome-less field: the floating toolbar and the slash menu remain.
variablesreadonly 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