Editable renders the editable document surface for the nearest Plite 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.
<Plite editor={editor}>
<Editable />
</Plite><Plite editor={editor}>
<Editable />
</Plite>type EditableProps = {
autoFocus?: boolean;
className?: string;
decorate?: (entry: NodeEntry) => EditableDecoration[];
decorateDirtiness?: PliteSourceDirtiness;
decorateRuntimeScope?: PliteProjectionRuntimeScope;
disableDefaultStyles?: boolean;
id?: string;
domStrategy?:
| { type: "full" }
| {
type: "virtualized";
layout: {
pageItems: readonly PageMountItem[];
topLevelItems: readonly TopLevelMountItem[];
visiblePageItems: readonly PageMountItem[];
};
}
| null;
onBeforeInput?: React.FormEventHandler<HTMLDivElement>;
onDOMBeforeInput?: (
event: InputEvent,
context: EditableDOMBeforeInputContext
) => boolean | EditableRepairRequest | void;
onKeyDown?: EditableKeyDownHandler;
onDOMStrategyMetrics?: (metrics: EditableDOMStrategyMetrics) => void;
onPaste?: React.ClipboardEventHandler<HTMLDivElement>;
placeholder?: React.ReactNode;
readOnly?: boolean;
renderElement?: (props: RenderElementProps) => React.ReactNode;
renderLeaf?: (props: RenderLeafProps) => React.ReactNode;
renderPlaceholder?: (props: RenderPlaceholderProps) => React.ReactNode;
renderSegment?: (
segment: EditableTextSegment,
children: React.ReactNode
) => 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;
};type EditableProps = {
autoFocus?: boolean;
className?: string;
decorate?: (entry: NodeEntry) => EditableDecoration[];
decorateDirtiness?: PliteSourceDirtiness;
decorateRuntimeScope?: PliteProjectionRuntimeScope;
disableDefaultStyles?: boolean;
id?: string;
domStrategy?:
| { type: "full" }
| {
type: "virtualized";
layout: {
pageItems
Editable also accepts safe div attributes such as aria-*, data-*, and event handlers that are not owned by Plite.
DOMStrategyOptions is either 'auto', 'staged', 'full', an object with
{ type: 'auto' | 'staged' | 'full' }, or the experimental object form
{ type: 'virtualized', estimatedBlockSize?, overscan?, threshold? }.
scrollSelectionIntoView defaults to defaultScrollSelectionIntoView. Import
the helper when custom scroll behavior should wrap Plite's default caret and
selection scrolling instead of replacing it completely.
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-plite-inline"?: true;
"data-plite-node": "element";
"data-plite-path": string;
"data-plite-node-key": NodeKey;
"data-plite-void"?: true;
ref: React.RefCallback<HTMLElement>;
};
children: React.ReactNode;
element: Element;
isInline: boolean;
slots: EditableElementSlots;
};type RenderElementProps = {
attributes: {
"data-plite-inline"?: true;
"data-plite-node": "element";
"data-plite-path": string;
"data-plite-node-key": NodeKey;
"data-plite-void"?: true;
ref: React.RefCallback<HTMLElement>;
};
children: React.ReactNode;
element: Element;
isInline: boolean;
slots: EditableElementSlots;
};Use renderElement for normal elements that render Plite-managed children.
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>;
}
};Always spread attributes on the top-level DOM element and render children.
Use renderVoid for void elements. A void renderer returns visible content only.
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 = useEditor();
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 = useEditor();
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, decorations, and a stable text path, but not
the underlying text string or segment offsets. Render 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 leaves.
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 and decoration presentation.
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>
);
};Use Editable.decorate for simple editor-local ranges such as a one-off search
match or lightweight syntax highlight.
<Editable
decorate={([node, path]) => {
if (!TextApi.isText(node)) return [];
const start = node.text.indexOf(query);
return start === -1
? []
: [
{
anchor: { path, offset: start },
data: { search: true },
focus: { path, offset: start + query.length },
},
];
}}
renderSegment={(segment, children) =>
segment.slices.some((slice) => slice.data?.search) ? (
<mark>{children}</mark>
) : (
children
)
}
/><Editable
decorate={([node, path]) => {
if (!TextApi.isText(node)) return [];
const start = node.text.indexOf(query);
return start === -1
? []
: [
{
anchor: { path, offset: start },
data: { search: true },
focus: { path, offset: start + query.length },
},
];
}}
renderSegment={(segment, children
decorate is a convenience adapter over the projection runtime. Use
provider-owned decorationSources when the ranges are shared with other UI,
come from external state, update frequently, or need source-scoped refreshes.
Use Projection And Overlays to
choose between local decorations, decoration sources, annotations, and widgets.
Use decorateDirtiness and decorateRuntimeScope when a decoration callback
depends on external projection state and can name which runtime targets should
refresh.
renderSegment renders text after projection sources split it into projected slices. Use it for search results, comments, diagnostics, and other render-time overlays.
<Editable
renderSegment={(segment, children) =>
segment.slices.length > 0 ? <mark>{children}</mark> : children
}
/><Editable
renderSegment={(segment, children) =>
segment.slices.length > 0 ? <mark>{children}</mark> : children
}
/>Normal apps should pass decorationSources and annotationStore to Plite, then render projected text through renderSegment.
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 extension 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 {
defineExtension,
editorCommands,
RangeApi,
} from "@platejs/plite";
const markdown = defineExtension("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 {
defineExtension,
editorCommands,
RangeApi,
} from "@platejs/plite";
const markdown = defineExtension("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.
Put decoration sources and the annotation store on Plite, not Editable. The provider owns editor-level projection sources so the editor surface, toolbar, and overlay UI read the same committed projection.
Use Projection And Overlays for the
cross-package projection model.
<Plite
annotationStore={commentStore}
decorationSources={[searchSource]}
editor={editor}
>
<Editable renderSegment={renderSearchMatch} />
<CommentsSidebar store={commentStore} />
</Plite><Plite
annotationStore={commentStore}
decorationSources={[searchSource]}
editor={editor}
>
<Editable renderSegment={renderSearchMatch} />
<CommentsSidebar store={commentStore} />
</Plite>Inline, void, selectable, and read-only behavior belongs to the editor schema.
import { defineEditorSchema, schema } from "@platejs/plite";
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 = usePliteEditor({
extensions: [MentionSchema] as const,
initialValue: [{ type: "paragraph", children: [{ text: "" }] }],
});import { defineEditorSchema, schema } from "@platejs/plite";
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 extensions. Keep raw Editable focused on rendering and DOM events.
Editable keeps large documents DOM-bounded by default. Use
domStrategy="auto" for bounded partial-DOM rendering. Plite keeps coarse
groups covered by model-backed boundaries and mounts a small active window
inside the opened group; the surrounding range stays selectable and copyable
through boundary policy until it materializes. Use domStrategy="staged" when a
product needs eventual native DOM coverage for the whole document, or
domStrategy="full" to render the full document surface for debugging.
Use onDOMStrategyMetrics to wire production RUM or a Datadog dashboard. The
callback runs after commit and reports the current document cohort, requested
strategy, effective strategy, degradation mode, mounted/pending counts, DOM
coverage boundary counts, visible DOM node count, and editable descendant count.
<Editable
domStrategy="auto"
onDOMStrategyMetrics={(metrics) => {
datadogRum.addAction("plite.dom_strategy.surface", metrics);
}}
/><Editable
domStrategy="auto"
onDOMStrategyMetrics={(metrics) => {
datadogRum.addAction("plite.dom_strategy.surface", metrics);
}}
/>Track dashboards by interaction name, cohort, document size, requested strategy,
effective strategy, degradation mode, native surface completion, boundary count,
visible DOM count, editable descendant count, custom renderer flag, browser,
mobile/desktop, IME state, and app version. Virtualized and partial-DOM
metrics are bounded-surface rows. staged-warmup metrics are staged
materialization rows. Do not mix either bucket with complete full-DOM rows.
When a layout runtime owns page virtualization, pass its immutable mount data inside the virtualized strategy. Keeping the data on the discriminated strategy prevents full-DOM surfaces from carrying an irrelevant layout contract.
<Editable
domStrategy={{
type: "virtualized",
layout: {
pageItems,
topLevelItems,
visiblePageItems,
},
}}
/><Editable
domStrategy={{
type: "virtualized",
layout: {
pageItems,
topLevelItems,
visiblePageItems,
},
}}
/>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.
const renderElement = ({ children, element, slots }) => {
if (element.type === "section") {
return (
<EditableElement>
{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",
})}
</EditableElement>
);
}
if (element.type === "hidden-header") {
return (
<EditableElement>
{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",
})}
</EditableElement>
);
}
return <EditableElement>{children}</EditableElement>;
};const renderElement = ({ children, element, slots }) => {
if (element.type === "section") {
return (
<EditableElement>
{React.Children.toArray(children)[0]}
{slots.contentBoundary({
mounted: !element.collapsed,
onMaterialize: () =>
openSection(editor.key(element)),
renderPlaceholder: ({ materialize }) => (
<button onClick={materialize} type="button">
Show section
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, findPolicy, and onMaterialize.
Pass root when one editor renders a named document root.
<Plite editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Plite><Plite editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Plite>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.