From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Introduction
  • Installation
    • Plate UI
      • Next.js
      • React
    • Manual
    • RSC
    • Node.js
    • Local Docs
    • MCP
  • Releases

Excalidraw

PreviousNext

Void Excalidraw drawing blocks stored inside Plate values.

Excalidraw Element

Excalidraw adds a void excalidraw element that embeds the Excalidraw canvas in the editor. The node stores Excalidraw elements and state under data. This page covers kit setup, insertion, persistence shape, and the client-only registry UI.

Loading…
EquationFootnote

On This Page

FeaturesFast PathAdd The KitRender The ElementAdd An Insert ActionOwnershipManual SetupInstall PackageAdd The PluginInsert A DrawingValue ShapeUI BehaviorAPI Reference
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Void excalidraw block element.
  • Descriptor-scoped editor.plugin(BaseExcalidrawPlugin).update.insert(props, options) command.
  • Excalidraw elements and app state stored on the node.
  • Dynamic Excalidraw component loading in the React hook.
  • Change deduplication before writing canvas data back to Plite.
  • Read-only mode through Excalidraw viewModeEnabled.
Report an issue

Fast Path

Add The Kit

ExcalidrawKit installs ExcalidrawPlugin with the registry ExcalidrawElement.

'use client';
 
import type { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import type { AppState, ExcalidrawProps } from '@excalidraw/excalidraw/types';
import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import {
  type PlateElementProps,
  PlateElement,
  useEditor,
  useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
 
import '@excalidraw/excalidraw/index.css';
 
export function ExcalidrawElement(
  props: PlateElementProps<typeof ExcalidrawPlugin>
) {
  const { children, element } = props;
  const [Excalidraw, setExcalidraw] = React.useState<
    (typeof import('@excalidraw/excalidraw'))['Excalidraw'] | null
  >(null);
  const editor = useEditor();
  const readOnly = useEditorReadOnly();
  const lastSavedDataRef = React.useRef(
    element.data ? JSON.stringify(element.data) : null
  );
 
  React.useEffect(() => {
    void import('@excalidraw/excalidraw').then((module) => {
      setExcalidraw(() => module.Excalidraw);
    });
  }, []);
 
  // Excalidraw treats initialData as an initialization boundary and mutates it.
  const initialData = React.useMemo(
    () => ({
      appState: element.data?.state
        ? (structuredClone(element.data.state) as Partial<AppState>)
        : undefined,
      elements: element.data?.elements
        ? (structuredClone(
            element.data.elements
          ) as unknown as readonly OrderedExcalidrawElement[])
        : [],
      libraryItems: [],
      scrollToContent: true,
    }),
    [element.data]
  );
 
  const excalidrawProps = {
    autoFocus: false,
    initialData,
    onChange: readOnly
      ? undefined
      : (
          elements: readonly OrderedExcalidrawElement[],
          state: Partial<AppState>
        ) => {
          const dataJson = JSON.stringify({ elements, state });
 
          if (lastSavedDataRef.current === dataJson) return;
 
          const path = editor.read.nodes.path(element);
 
          if (!path) return;
 
          lastSavedDataRef.current = dataJson;
          editor.update.nodes.set(
            { data: JSON.parse(dataJson) as NonNullable<typeof element.data> },
            { at: path }
          );
        },
  } satisfies ExcalidrawProps;
 
  return (
    <PlateElement {...props}>
      <div contentEditable={false}>
        <div
          className={cn(
            'mx-auto aspect-video h-[600px] w-[min(100%,600px)] overflow-hidden rounded-sm border'
          )}
        >
          {Excalidraw && (
            <Excalidraw {...excalidrawProps} viewModeEnabled={readOnly} />
          )}
        </div>
      </div>
      {children}
    </PlateElement>
  );
}
 
export const ExcalidrawKit = [
  ExcalidrawPlugin.configure({ component: ExcalidrawElement }),
];
'use client';
 
import type { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import type { AppState, ExcalidrawProps } from '@excalidraw/excalidraw/types';
import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import {
  type PlateElementProps,
  PlateElement,
  useEditor,
  useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
 
import '@excalidraw/excalidraw/index.css';
 
export function ExcalidrawElement
















































































import { createPlateEditor } from 'platejs/react';
 
import { ExcalidrawKit } from '@/components/editor/excalidraw';
 
export const editor = createPlateEditor({
  plugins: ExcalidrawKit,
});
import { createPlateEditor } from 'platejs/react';
 
import { ExcalidrawKit } from '@/components/editor/excalidraw';
 
export const editor = createPlateEditor({
  plugins: ExcalidrawKit,

Render The Element

excalidraw owns the client component, Excalidraw CSS import, fixed canvas frame, and read-only view mode.

'use client';
 
import type { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import type { AppState, ExcalidrawProps } from '@excalidraw/excalidraw/types';
import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import {
  type PlateElementProps,
  PlateElement,
  useEditor,
  useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
 
import '@excalidraw/excalidraw/index.css';
 
export function
















































































Add An Insert Action

The registry insert toolbar calls the installed Excalidraw command.

components/editor/transforms.ts
import { BaseExcalidrawPlugin } from '@platejs/excalidraw';
import { PLUGINS } from 'platejs';
 
export const insertBlockMap = {
  [PLUGINS.excalidraw]: (editor) =>
    editor.plugin(BaseExcalidrawPlugin).update.insert({}, { select: true }),
};
components/editor/transforms.ts
import { BaseExcalidrawPlugin } from '@platejs/excalidraw';





Ownership

LayerOwnerWhat It Does
@platejs/excalidrawPackageExports BaseExcalidrawPlugin, ExcalidrawElement, and ExcalidrawDataState.
@platejs/excalidraw/reactPackageExports the React ExcalidrawPlugin descriptor.
excalidrawRegistryAdds ExcalidrawPlugin.configure({ component: ExcalidrawElement }).
excalidrawRegistry UIDynamically renders @excalidraw/excalidraw inside a Plate element.
App persistenceApp codeStores the Plate value that contains Excalidraw element data.

BaseExcalidrawPlugin owns the standard descriptor-scoped insert update.

Manual Setup

Install Package

pnpm add @platejs/excalidraw
pnpm add @platejs/excalidraw

Add The Plugin

Use the React plugin when the editor renders the Excalidraw canvas.

import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import { createPlateEditor } from 'platejs/react';
 
import { ExcalidrawElement } from '@/components/editor/excalidraw';
 
export const editor = createPlateEditor({
  plugins: [ExcalidrawPlugin.configure({ component: ExcalidrawElement })],
});
import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import { createPlateEditor } from 'platejs/react';
 
import { ExcalidrawElement } from '@/components/editor/excalidraw';
 
export const editor = createPlateEditor({
  plugins: [ExcalidrawPlugin.configure({ component: ExcalidrawElement })],
});

Insert A Drawing

The plugin portal's insert method is the standard descriptor-bound block insertion. Pass at for an exact location; otherwise it inserts after the selected block. It is a no-op without a selection or explicit at.

import { BaseExcalidrawPlugin } from '@platejs/excalidraw';
 
editor.plugin(BaseExcalidrawPlugin).update.insert(
  {
    data: {
      elements: [],
      state: {
        viewBackgroundColor: '#ffffff',
      },
    },
  },
  { select: true }
);
import { BaseExcalidrawPlugin } from '@platejs/excalidraw';
 
editor.plugin









Value Shape

ExcalidrawElement is a void element. The drawing payload lives in data, not in text children.

const value = [
  {
    children: [{ text: '' }],
    data: {
      elements: [
        {
          id: 'shape-1',
          type: 'rectangle',
          x: 100,
          y: 100,
        },
      ],
      state: {
        viewBackgroundColor: '#ffffff',
      },
    },
    type: 'excalidraw',
  },
];
const value = [
  {
    children: [{ text: '' }],
    data: {
      elements: [
        {
          id: 'shape-1',
          type: 'rectangle',
          x: 100,
          y: 100,
        },
      ],
      state: {
        viewBackgroundColor: '#ffffff',
      },
    },
    type: 'excalidraw',
  },
];
FieldTypeNotes
type'excalidraw'Persisted element type owned by BaseExcalidrawPlugin.
children[{ text: '' }]Required Plite child for the void element.
data.elementsExcalidraw elementsStored as partial Excalidraw elements.
data.stateExcalidraw app stateStored as imported Excalidraw app state.

Markdown serialization is not owned by @platejs/excalidraw. Persist the Plate value when you need to keep drawings.

UI Behavior

The copied registry element owns the Excalidraw React integration directly.

SurfaceBehavior
Component loadingDynamically imports @excalidraw/excalidraw and returns the loaded component.
Initial dataDeep-clones element.data.state, element.data.elements, libraryItems, and scrollToContent.
EditingonChange writes { elements, state } back to the node.
DeduplicationUses deep equality to skip writes when canvas data did not change.
Read-only modeRemoves the write handler and enables Excalidraw viewModeEnabled.
Canvas frameRegistry UI renders a bordered aspect-video frame capped at 600px.

The registry element imports @excalidraw/excalidraw/index.css, so custom copies need the same stylesheet.

API Reference

APIPackageUse
BaseExcalidrawPlugin@platejs/excalidrawHeadless void element plugin.
ExcalidrawPlugin@platejs/excalidraw/reactReact Excalidraw plugin.
editor.plugin(BaseExcalidrawPlugin).update.insert(props?, options?)BaseExcalidrawPluginInserts a void Excalidraw node at options.at or after the selected block.
ExcalidrawElement@platejs/excalidrawElement shape with optional data.
ExcalidrawDataState@platejs/excalidrawData shape for stored Excalidraw elements and app state.
(
props: PlateElementProps<typeof ExcalidrawPlugin>
) {
const { children, element } = props;
const [Excalidraw, setExcalidraw] = React.useState<
(typeof import('@excalidraw/excalidraw'))['Excalidraw'] | null
>(null);
const editor = useEditor();
const readOnly = useEditorReadOnly();
const lastSavedDataRef = React.useRef(
element.data ? JSON.stringify(element.data) : null
);
React.useEffect(() => {
void import('@excalidraw/excalidraw').then((module) => {
setExcalidraw(() => module.Excalidraw);
});
}, []);
// Excalidraw treats initialData as an initialization boundary and mutates it.
const initialData = React.useMemo(
() => ({
appState: element.data?.state
? (structuredClone(element.data.state) as Partial<AppState>)
: undefined,
elements: element.data?.elements
? (structuredClone(
element.data.elements
) as unknown as readonly OrderedExcalidrawElement[])
: [],
libraryItems: [],
scrollToContent: true,
}),
[element.data]
);
const excalidrawProps = {
autoFocus: false,
initialData,
onChange: readOnly
? undefined
: (
elements: readonly OrderedExcalidrawElement[],
state: Partial<AppState>
) => {
const dataJson = JSON.stringify({ elements, state });
if (lastSavedDataRef.current === dataJson) return;
const path = editor.read.nodes.path(element);
if (!path) return;
lastSavedDataRef.current = dataJson;
editor.update.nodes.set(
{ data: JSON.parse(dataJson) as NonNullable<typeof element.data> },
{ at: path }
);
},
} satisfies ExcalidrawProps;
return (
<PlateElement {...props}>
<div contentEditable={false}>
<div
className={cn(
'mx-auto aspect-video h-[600px] w-[min(100%,600px)] overflow-hidden rounded-sm border'
)}
>
{Excalidraw && (
<Excalidraw {...excalidrawProps} viewModeEnabled={readOnly} />
)}
</div>
</div>
{children}
</PlateElement>
);
}
export const ExcalidrawKit = [
ExcalidrawPlugin.configure({ component: ExcalidrawElement }),
];
});
ExcalidrawElement
(
props: PlateElementProps<typeof ExcalidrawPlugin>
) {
const { children, element } = props;
const [Excalidraw, setExcalidraw] = React.useState<
(typeof import('@excalidraw/excalidraw'))['Excalidraw'] | null
>(null);
const editor = useEditor();
const readOnly = useEditorReadOnly();
const lastSavedDataRef = React.useRef(
element.data ? JSON.stringify(element.data) : null
);
React.useEffect(() => {
void import('@excalidraw/excalidraw').then((module) => {
setExcalidraw(() => module.Excalidraw);
});
}, []);
// Excalidraw treats initialData as an initialization boundary and mutates it.
const initialData = React.useMemo(
() => ({
appState: element.data?.state
? (structuredClone(element.data.state) as Partial<AppState>)
: undefined,
elements: element.data?.elements
? (structuredClone(
element.data.elements
) as unknown as readonly OrderedExcalidrawElement[])
: [],
libraryItems: [],
scrollToContent: true,
}),
[element.data]
);
const excalidrawProps = {
autoFocus: false,
initialData,
onChange: readOnly
? undefined
: (
elements: readonly OrderedExcalidrawElement[],
state: Partial<AppState>
) => {
const dataJson = JSON.stringify({ elements, state });
if (lastSavedDataRef.current === dataJson) return;
const path = editor.read.nodes.path(element);
if (!path) return;
lastSavedDataRef.current = dataJson;
editor.update.nodes.set(
{ data: JSON.parse(dataJson) as NonNullable<typeof element.data> },
{ at: path }
);
},
} satisfies ExcalidrawProps;
return (
<PlateElement {...props}>
<div contentEditable={false}>
<div
className={cn(
'mx-auto aspect-video h-[600px] w-[min(100%,600px)] overflow-hidden rounded-sm border'
)}
>
{Excalidraw && (
<Excalidraw {...excalidrawProps} viewModeEnabled={readOnly} />
)}
</div>
</div>
{children}
</PlateElement>
);
}
export const ExcalidrawKit = [
ExcalidrawPlugin.configure({ component: ExcalidrawElement }),
];
'use client';
 
import type { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import type { AppState, ExcalidrawProps } from '@excalidraw/excalidraw/types';
import { ExcalidrawPlugin } from '@platejs/excalidraw/react';
import {
  type PlateElementProps,
  PlateElement,
  useEditor,
  useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
 
import '@excalidraw/excalidraw/index.css';
 
export function ExcalidrawElement(
  props: PlateElementProps<typeof ExcalidrawPlugin>
) {
  const { children, element } = props;
  const [Excalidraw, setExcalidraw] = React.useState<
    (typeof import('@excalidraw/excalidraw'))['Excalidraw'] | null
  >(null);
  const editor = useEditor();
  const readOnly = useEditorReadOnly();
  const lastSavedDataRef = React.useRef(
    element.data ? JSON.stringify(element.data) : null
  );
 
  React.useEffect(() => {
    void import('@excalidraw/excalidraw').then((module) => {
      setExcalidraw(() => module.Excalidraw);
    });
  }, []);
 
  // Excalidraw treats initialData as an initialization boundary and mutates it.
  const initialData = React.useMemo(
    () => ({
      appState: element.data?.state
        ? (structuredClone(element.data.state) as Partial<AppState>)
        : undefined,
      elements: element.data?.elements
        ? (structuredClone(
            element.data.elements
          ) as unknown as readonly OrderedExcalidrawElement[])
        : [],
      libraryItems: [],
      scrollToContent: true,
    }),
    [element.data]
  );
 
  const excalidrawProps = {
    autoFocus: false,
    initialData,
    onChange: readOnly
      ? undefined
      : (
          elements: readonly OrderedExcalidrawElement[],
          state: Partial<AppState>
        ) => {
          const dataJson = JSON.stringify({ elements, state });
 
          if (lastSavedDataRef.current === dataJson) return;
 
          const path = editor.read.nodes.path(element);
 
          if (!path) return;
 
          lastSavedDataRef.current = dataJson;
          editor.update.nodes.set(
            { data: JSON.parse(dataJson) as NonNullable<typeof element.data> },
            { at: path }
          );
        },
  } satisfies ExcalidrawProps;
 
  return (
    <PlateElement {...props}>
      <div contentEditable={false}>
        <div
          className={cn(
            'mx-auto aspect-video h-[600px] w-[min(100%,600px)] overflow-hidden rounded-sm border'
          )}
        >
          {Excalidraw && (
            <Excalidraw {...excalidrawProps} viewModeEnabled={readOnly} />
          )}
        </div>
      </div>
      {children}
    </PlateElement>
  );
}
 
export const ExcalidrawKit = [
  ExcalidrawPlugin.configure({ component: ExcalidrawElement }),
];
import
{ PLUGINS }
from
'platejs'
;
export const insertBlockMap = {
[PLUGINS.excalidraw]: (editor) =>
editor.plugin(BaseExcalidrawPlugin).update.insert({}, { select: true }),
};
(BaseExcalidrawPlugin).update.
insert
(
{
data: {
elements: [],
state: {
viewBackgroundColor: '#ffffff',
},
},
},
{ select: true }
);