Editable renders the editable document surface for the nearest EditorRoot provider; app code customizes what content looks like while the runtime owns browser selection, DOM repair, void shells, and editing events. Use Editing Behavior for the event-to-commit pipeline and Selection And DOM for model selection, native selection, DOM coverage, and large-document selection proof.
<EditorRoot editor={editor}>
<Editable />
</EditorRoot><EditorRoot editor={editor}>
<Editable />
</EditorRoot>import type { EditableHandlerResult } from "plitejs/react";
type EditableProps = {
autoFocus?: boolean;
className?: string;
disableDefaultStyles?: boolean;
id?: string;
ignoreBlankEditableRootClicks?: boolean;
onBeforeInput?: React.FormEventHandler<HTMLDivElement>;
onDOMBeforeInput?: (
event: InputEvent,
context: EditableDOMBeforeInputContext
) => EditableHandlerResult;
onKeyDown?: EditableKeyDownHandler;
onPaste?: React.ClipboardEventHandler<HTMLDivElement>;
placeholder?: React.ReactNode;
readOnly?: boolean;
ref?: React.Ref<HTMLDivElement>;
renderElement?: (props: RenderElementProps) => React.ReactNode;
renderLeaf?: (props: RenderLeafProps) => React.ReactNode;
renderPlaceholder?: (props: RenderPlaceholderProps) => React.ReactNode;
renderText?: (props: RenderTextProps) => React.ReactNode;
renderVoid?: (props: RenderVoidProps) => React.ReactNode;
root?: RootKey;
scrollSelectionIntoView?: (
editor: Editor,
domRange: globalThis.Range
) => void;
spellCheck?: boolean;
style?: React.CSSProperties;
};import type { EditableHandlerResult } from "plitejs/react";
type EditableProps = {
autoFocus?: boolean;
className?: string;
disableDefaultStyles?: boolean;
id?: string;
ignoreBlankEditableRootClicks?: boolean;
onBeforeInput?: React.FormEventHandler<HTMLDivElement>;
onDOMBeforeInput?: (
event: InputEvent,
context: EditableDOMBeforeInputContext
) => EditableHandlerResult;
onKeyDown?: EditableKeyDownHandler
Editable also accepts safe div attributes such as aria-*, data-*, and event handlers that are not owned by Plite.
scrollSelectionIntoView scrolls the caret or selection focus into view by
default. Supply a callback to own scrolling in a custom viewport.
Use raw render props for content rendering. Keep renderer functions stable by defining them at module scope or creating them once with the editor.
type RenderElementProps = {
attributes: {
"data-editor-inline"?: true;
"data-editor-node": "element";
"data-editor-path": string;
"data-editor-node-key": string;
"data-editor-void"?: true;
ref: React.RefCallback<HTMLElement>;
};
children: React.ReactNode;
element: Element;
isInline: boolean;
slots: EditableElementSlots;
};type RenderElementProps = {
attributes: {
"data-editor-inline"?: true;
"data-editor-node": "element";
"data-editor-path": string;
"data-editor-node-key": string;
"data-editor-void"?: true;
ref: React.RefCallback<HTMLElement>;
};
children: React.ReactNode;
element: Element;
isInline: boolean;
slots: EditableElementSlots;
};Use renderElement for non-void elements. Each element chooses native Plite
children or an external text view.
const renderElement = ({ attributes, children, element }) => {
switch (element.type) {
case "code":
return (
<pre {...attributes}>
<code>{children}</code>
</pre>
);
default:
return <p {...attributes}>{children}</p>;
}
};const renderElement = ({ attributes, children, element }) => {
switch (element.type) {
case "code":
return (
<pre {...attributes}>
<code>{children}</code>
</pre>
);
default:
return <p {...attributes}>{children}</p>;
}
};Spread attributes on the top-level DOM element. Native renderers render
children or slots.children(); external renderers use only
slots.externalText(...) for that element's text.
A native renderer that leaves an editable child without DOM, and without a
DOM coverage boundary over it, breaks selection in
that child. Plite reports each such child once to the editor's
lifecycleErrorSink with source: "render-element". In development it reports
after the element renders, with phase: "commit". In every build it reports
when a commit leaves the focused editor's selection in that child, with
phase: "selection-export". A child that mounts within two animation frames,
such as one portaled into an iframe that has already loaded, does not report.
A child that waits longer, such as one behind a Suspense fallback or one
portaled into an iframe that is still loading, reports unless a boundary covers
it while it loads, as
Children that load shows.
Use slots.externalText({ adapter, ariaLabel, config? }) for a non-void block
whose schema and value contain exactly one direct Text. Plite owns the value,
selection, history, and remote changes. The adapter owns its DOM and local input.
Do not read children in the external branch: native children are lazy, and
each element may have only one text projection. Rich children and partial marks
remain on the native path. No fixed height or document virtualization is
required.
See External Text Views for the adapter protocol and the raw example for a textarea demonstration.
Use renderVoid for void elements. A void renderer returns visible content only.
ImageElement is the application component defined in the next example.
const renderVoid = ({ element }) => {
switch (element.type) {
case "image":
return <ImageElement element={element} />;
default:
return null;
}
};const renderVoid = ({ element }) => {
switch (element.type) {
case "image":
return <ImageElement element={element} />;
default:
return null;
}
};Do not render children, hidden text anchors, or shell wrappers in normal void renderers. Plite renders the shell and model anchor for you.
If a void needs selected UI, subscribe from inside the void component.
const ImageElement = ({ element }) => {
const selected = useElementSelected({ mode: "collapsed" });
return <img data-selected={selected || undefined} src={element.url} />;
};const ImageElement = ({ element }) => {
const selected = useElementSelected({ mode: "collapsed" });
return <img data-selected={selected || undefined} src={element.url} />;
};If an event handler needs the current location of the rendered element, resolve the path inside the handler.
const ImageElement = ({ element }) => {
const editor = useEditorContext();
return (
<button
onClick={() => {
const path = editor.api.dom.resolvePath(element);
if (!path) return;
editor.update((tx) => {
tx.nodes.remove({ at: path, voids: true });
});
}}
/>
);
};const ImageElement = ({ element }) => {
const editor = useEditorContext();
return (
<button
onClick={() => {
const path = editor.api.dom.resolvePath(element);
if (!path) return;
editor.update((tx) => {
tx.nodes.remove({ at: path, voids: true });
});
}}
/>
);
};Use renderLeaf for text marks.
Live leaf renderers receive marks, an optional leaf position, and a stable text
path, but not Decoration data. Decoration attributes are already applied inside
children. Render those children for the text.
A custom renderLeaf stays on the conservative model-owned input path because
arbitrary returned DOM cannot be proven safe. Internally compiled Plate mark
renderers can publish native DOM capability without an app-owned flag.
const renderLeaf = ({ attributes, children, leaf }) => {
return (
<span
{...attributes}
style={{
fontWeight: leaf.bold ? "bold" : "normal",
fontStyle: leaf.italic ? "italic" : "normal",
}}
>
{children}
</span>
);
};const renderLeaf = ({ attributes, children, leaf }) => {
return (
<span
{...attributes}
style={{
fontWeight: leaf.bold ? "bold" : "normal",
fontStyle: leaf.italic ? "italic" : "normal",
}}
>
{children}
</span>
);
};Use renderText when you need to wrap a whole text node, regardless of how
decorations split it into rendered segments.
renderText is the advanced model-owned lane for whole-text DOM structure. A
custom text renderer deliberately gives up direct native DOM text sync. Prefer
renderLeaf for ordinary mark presentation and Decoration attributes for
transient paint.
const renderText = ({ attributes, children, text }) => {
return (
<span {...attributes} data-commented={text.commentId || undefined}>
{children}
</span>
);
};const renderText = ({ attributes, children, text }) => {
return (
<span {...attributes} data-commented={text.commentId || undefined}>
{children}
</span>
);
};Editable renders decorations from the nearest EditorRoot provider. Each
decoration wraps its text range in a span with the supplied className,
style, aria-*, and data-* attributes. It needs no render callback.
const searchDecorations = {
id: "search",
read: ({ entry: [node, path] }) => {
if (!TextApi.isText(node)) return [];
const start = node.text.indexOf(query);
return start === -1
? []
: [
{
attributes: { className: "search-match" },
key: `search:${path.join(".")}:${start}`,
range: {
anchor: { path, offset: start },
focus: { path, offset: start + query.length },
},
},
];
},
} satisfies DecorationSource;
<EditorRoot decorations={[searchDecorations]} editor={editor}>
<Editable />
</EditorRoot>;const searchDecorations = {
id: "search",
read: ({ entry: [node, path] }) => {
if (!TextApi.isText(node)) return [];
const start = node.text.indexOf(query);
return start === -1
? []
: [
{
attributes: { className: "search-match" },
key: `search:${path.join(".")}:${start}`,
range: {
Use Decorations, annotations, and widgets for source refresh, durable range identity, and anchored UI.
Use placeholder for the normal empty-editor message.
<Editable placeholder="Start typing..." /><Editable placeholder="Start typing..." />Use renderPlaceholder when the placeholder needs custom markup.
<Editable
placeholder="Start typing..."
renderPlaceholder={({ attributes, children }) => (
<span
{...attributes}
style={{ ...attributes.style, color: "#6b7280", opacity: 0.6 }}
>
{children}
</span>
)}
/><Editable
placeholder="Start typing..."
renderPlaceholder={({ attributes, children }) => (
<span
{...attributes}
style={{ ...attributes.style, color: "#6b7280", opacity: 0.6 }}
>
{children}
</span>
)}
/>Keep the provided attributes. They supply the structural positioning, pointer-inert behavior, and selection isolation that make the placeholder editor chrome instead of document content. The host owns presentation such as color, opacity, and text decoration.
Use onKeyDown for UI hotkeys on one Editable instance.
<Editable
onKeyDown={(event, { editor }) => {
if (event.key === "`" && event.ctrlKey) {
editor.update((tx) => {
tx.nodes.set({ type: "code" });
});
return true;
}
}}
/><Editable
onKeyDown={(event, { editor }) => {
if (event.key === "`" && event.ctrlKey) {
editor.update((tx) => {
tx.nodes.set({ type: "code" });
});
return true;
}
}}
/>Use plugin commands for model behavior such as deleting, inserting text,
and inserting breaks. Those handlers run for keyboard input, native input,
programmatic commands, and tests.
Use Clipboard And Paste for paste,
copy, drop, and fragment import ownership.
import {
definePlugin,
editorCommands,
RangeApi,
} from "plitejs";
const markdown = definePlugin("markdown", {
commands: ({ handle }) => [
handle(editorCommands.insertText, ({ input, state }) => {
const selection = state.selection();
if (
input.text !== " " ||
!selection ||
!RangeApi.isCollapsed(selection)
) {
return false;
}
return state.transaction((tx) => {
tx.nodes.set({ type: "code" });
tx.text.insert(input.text, input.options);
});
}),
],
});import {
definePlugin,
editorCommands,
RangeApi,
} from "plitejs";
const markdown = definePlugin("markdown", {
commands: ({ handle }) => [
handle(editorCommands.insertText, ({ input, state }) => {
const selection = state.selection();
if (
input.text !== " " ||
!selection ||
!RangeApi.isCollapsed(selection)
) {
return false
onBeforeInput is the React form-event hook on the editable root. Use
onDOMBeforeInput only when you need the raw native InputEvent. Returning
true or calling event.preventDefault() marks the event handled.
Editable keeps its live canonical selection visible when focus moves to an
owned external control. Mark the focus target or any composed ancestor with
data-editor-keep-selection-visible:
<EditorRoot editor={editor}>
<Editable />
<button data-editor-keep-selection-visible="" type="button">
Edit link
</button>
</EditorRoot><EditorRoot editor={editor}>
<Editable />
<button data-editor-keep-selection-visible="" type="button">
Edit link
</button>
</EditorRoot>The exact Editable that loses focus paints an expanded range with
data-editor-inactive-selection or a collapsed caret with
data-editor-inactive-selection-caret. Focus returning to the editor or moving
to an unmarked target clears that paint.
Style the output hooks in product CSS:
[data-editor-inactive-selection] {
background: Highlight;
}
[data-editor-inactive-selection-caret] {
width: 2px;
background: currentColor;
}[data-editor-inactive-selection] {
background: Highlight;
}
[data-editor-inactive-selection-caret] {
width: 2px;
background: currentColor;
}This is built-in view behavior, not a prop or plugin. Editable stores only
private transition state and reads the current canonical selection for each
paint. It never copies a Range or changes document, selection, history,
clipboard, or collaboration state.
Inline, void, selectable, and read-only behavior belongs to the editor schema.
import { defineEditorSchema, schema } from "plitejs";
const MentionSchema = defineEditorSchema("schema:mentions", {
elements: {
mention: {
void: "markable-inline",
},
paragraph: {
content: schema.content.any(
[schema.content.text(), schema.content.type("mention")],
{ default: "text", min: 1 }
),
},
},
id: "mentions",
root: schema.content.type("paragraph", {
default: { type: "paragraph" },
min: 1,
}),
unknown: "reject",
version: 1,
});
const editor = useEditor({
plugins: [MentionSchema] as const,
initialValue: [{ type: "paragraph", children: [{ text: "" }] }],
});import { defineEditorSchema, schema } from "plitejs";
const MentionSchema = defineEditorSchema("schema:mentions", {
elements: {
mention: {
void: "markable-inline",
},
paragraph: {
content: schema.content.any(
[schema.content.text(), schema.content.type("mention")],
{ default: "text", min: 1 }
),
},
},
id: "mentions",
root: schema.content.type("paragraph", {
default: { type: "paragraph" },
min:
Product input rules belong in higher-level command layers or editor plugins. Keep raw Editable focused on rendering and DOM events.
Editable mounts the complete document DOM. This gives native browser find,
assistive technology, printing, and DOM integrations access to every rendered
block.
For an explicitly bounded surface, render
VirtualizedEditable from
plitejs/react/virtualized. The component choice defines the mounted view's
lifetime. Keep that choice stable while the view is focused or composing.
For paginated documents, use
PagedEditable. Pagination owns page geometry and its
optional page virtualization directly.
renderElement receives slots.contentBoundary for model content whose DOM is
intentionally not mounted. Use it for closed accordions, inactive tab panels,
collapsed sections, or hidden element shells that still exist in the Plite
value.
import { EditorElement } from "plitejs/react";
const renderElement = ({ children, element, slots }) => {
if (element.type === "section") {
return (
<EditorElement style={{ position: 'relative' }}>
{React.Children.toArray(children)[0]}
{slots.contentBoundary({
mounted: !element.collapsed,
onMaterialize: () =>
openSection(editor.key(element)),
renderPlaceholder: ({ materialize }) => (
<button onClick={materialize} type="button">
Show section
</button>
),
scope: { from: 1, type: "children" },
selectionPolicy: "materialize",
})}
</EditorElement>
);
}
if (element.type === "hidden-header") {
return (
<EditorElement style={{ position: 'relative' }}>
{slots.contentBoundary({
boundaryId: "hidden-header",
children: <button type="button">Show header</button>,
copyPolicy: "exclude",
mounted: !element.hidden,
reason: "app-hidden",
scope: { type: "self" },
selectionPolicy: "skip",
})}
</EditorElement>
);
}
return <EditorElement style={{ position: 'relative' }}>{children}</EditorElement>;
};import { EditorElement } from "plitejs/react";
const renderElement = ({ children, element, slots }) => {
if (element.type === "section") {
return (
<EditorElement style={{ position: 'relative' }}>
{React.Children.toArray(children)[0]}
{slots.contentBoundary({
mounted: !element.collapsed,
onMaterialize: () =>
openSection(editor.key(element)),
renderPlaceholder: ({ materialize }) => (
Boundary content is model-present but DOM-incomplete while mounted is false.
Plite maps selection, copy, paste, and DOM point import through the boundary
registry instead of resolving missing descendants with raw DOM lookups.
Use the dedicated DOM Coverage Boundaries page
for selectionPolicy, copyPolicy, and onMaterialize.
Pass root when one editor renders a named document root.
<EditorRoot editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</EditorRoot><EditorRoot editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</EditorRoot>Use slots.contentRoot(slot) from renderElement when an element owns an
editable child root, such as a synced block body.
const SyncedBlock = ({ attributes, element, slots }) => (
<section {...attributes}>
<header>Synced block</header>
{slots.contentRoot("body", { ariaLabel: "Synced block body" })}
</section>
);const SyncedBlock = ({ attributes, element, slots }) => (
<section {...attributes}>
<header>Synced block</header>
{slots.contentRoot("body", { ariaLabel: "Synced block body" })}
</section>
);See Roots for the value shape, tx.roots, root
chrome, and content-root ownership.
Use style or className for editor styling.
<Editable style={{ minHeight: 200 }} /><Editable style={{ minHeight: 200 }} />Pass disableDefaultStyles only when your CSS replaces Plite's default editable-surface styles.