From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
UNPUBLISHED
  • Overview
  • Why This Fork
  • Examples
Walkthroughs
  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Applying Custom Formatting
  • Executing Commands
  • Saving to a Database
  • Canonical Change Substrate
  • Improving Performance
Concepts
  • Interfaces
  • Nodes
  • Locations
  • Transforms
  • Document Changes
  • Commands
  • Editor
  • Extensions
  • Rendering
  • Serializing
  • Normalizing
  • Using TypeScript
  • Roots
  • Document State
  • Editing Behavior
  • Selection And DOM
  • Clipboard And Paste
  • Projection And Overlays
  • Schema
API
  • Anchor API
  • Location API
  • Path API
  • PointEntry API
  • Point API
  • Range API
  • Selection API
  • Location Types APIs
  • Span API
  • Editor
  • Element API
  • NodeEntry API
  • Node API
  • Node Types APIs
  • Text API
  • Debug Value Scrubbing
  • Transforms API
Libraries
  • Plite DOM
  • History Editor API
  • History Extension Setup
  • History
  • Plite History
  • Plite Hyperscript
  • Plite Layout
  • Annotations
  • DOM Coverage Boundaries
  • Editable Component
  • Plite React Event Handling
  • Virtualized Rendering
  • Plite React Hooks
  • React Editor Setup
  • React Editor
  • Plite React
  • Plite Component
  • Plite Yjs
  • Plite
General
  • Migration
  • Contributing
  • Docs Proof Map
  • FAQ
  • Resources

Editable Component

PreviousNext

Render the editable surface and configure browser events, rendering, overlays, and DOM strategy.

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.

Minimal Usage

<Plite editor={editor}>
  <Editable />
</Plite>
DOM Coverage BoundariesPlite React Event Handling

On This Page

Minimal UsageOn This PagePropsRender PropsrenderElementrenderVoidrenderLeafrenderTextdecoraterenderSegmentplaceholder And renderPlaceholderEvent PropsProjection SourcesDOM StrategyDOM Coverage BoundariesMultiple RootsStyling
Build your editor
Production-ready AI template and reusable components.
Get all-access
<Plite editor={editor}> <Editable /> </Plite>

On This Page

  • Props
  • Render Props
  • renderElement
  • renderVoid
  • renderLeaf
  • renderText
  • decorate
  • renderSegment
  • placeholder And renderPlaceholder
  • Event Props
  • Projection Sources
  • DOM Strategy
  • DOM Coverage Boundaries
  • Multiple Roots
  • Styling

Props

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.

Render Props

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;
};

renderElement

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.

renderVoid

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 });
        });
      }}
    />
  );
};

renderLeaf

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>
  );
};

renderText

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>
  );
};

decorate

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

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.

placeholder And renderPlaceholder

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.

Event Props

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.

Projection Sources

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.

DOM Strategy

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,
    },
  }}
/>

DOM Coverage Boundaries

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.

Multiple Roots

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.

Styling

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.

:
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;
};
)
=>
segment.slices.some((slice) => slice.data?.search) ? (
<mark>{children}</mark>
) : (
children
)
}
/>
}
return state.transaction((tx) => {
tx.nodes.set({ type: "code" });
tx.text.insert(input.text, input.options);
});
}),
],
});
1
,
}),
unknown: "reject",
version: 1,
});
const editor = usePliteEditor({
extensions: [MentionSchema] as const,
initialValue: [{ type: "paragraph", children: [{ text: "" }] }],
});
</
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>;
};