A practical guide to extending Excalidraw while following existing patterns.
Actions are the standard way to add user-triggerable operations (keyboard shortcuts, toolbar buttons, menu items).
Create packages/excalidraw/actions/actionMyFeature.ts:
import { register } from "./register";
import type { ActionResult } from "./types";
export const actionMyFeature = register({
name: "myFeature",
label: "My Feature",
icon: MyFeatureIcon, // optional, for toolbar
trackEvent: { category: "element" },
// Keyboard shortcut (optional)
keyTest: (event) =>
event.key === "m" && event[KEYS.CTRL_OR_CMD],
// Whether the action is available in current state
predicate: (elements, appState) => {
return appState.selectedElementIds.length > 0;
},
// The actual logic
perform: (elements, appState, formData, app) => {
// Your logic here
const newElements = /* transform elements */;
return {
elements: newElements,
appState: { ...appState /* state changes */ },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
// Panel component for the toolbar (optional)
PanelComponent: ({ elements, appState, updateData }) => (
<button onClick={() => updateData(null)}>My Feature</button>
),
});Add the export to packages/excalidraw/actions/index.ts:
export { actionMyFeature } from "./actionMyFeature";Create packages/excalidraw/actions/actionMyFeature.test.tsx:
import { render, unmountComponent } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { Keyboard } from "../tests/helpers/ui";
import { Excalidraw } from "../index";
describe("actionMyFeature", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
afterEach(() => unmountComponent());
it("should do the thing", () => {
// Arrange
API.setElements([API.createElement({ type: "rectangle" })]);
// Act
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyDown("m");
});
// Assert
expect(API.getElements()).toSatisfyMyCondition();
});
});captureUpdatecontrols undo/redo: useCaptureUpdateAction.IMMEDIATELYfor undoable actions- Return
falsefromperformto no-op (don't update state) formDatacomes fromPanelComponent'supdateData()callappprovides access toAppClassPropertiesfor imperative operations
When you need to add a new visual or behavioral property to elements.
In packages/element/src/types.ts, add the property to the relevant element type or base type:
type _ExcalidrawElementBase = {
// ... existing properties
myNewProp?: string;
};In packages/element/src/newElement.ts, include the default value:
export const newElement = (opts) => ({
// ... existing defaults
myNewProp: opts.myNewProp ?? "default",
});In packages/excalidraw/data/restore.ts, handle old elements that don't have the property:
// Inside restoreElement()
element.myNewProp = element.myNewProp ?? "default";If the property is user-editable, create a panel component in packages/excalidraw/components/ and register an action that changes it.
If the property affects how elements look, update the rendering in:
packages/excalidraw/renderer/staticScene.ts— for canvas renderingpackages/excalidraw/renderer/staticSvgScene.ts— for SVG export
Tools determine what happens when the user interacts with the canvas.
Add the tool type to packages/excalidraw/types.ts in the ActiveTool definition.
Update packages/excalidraw/components/Actions.tsx or the relevant toolbar component.
The main event handlers are in packages/excalidraw/components/App.tsx:
handleCanvasPointerDown()— start of interactionhandleCanvasPointerMove()— during interactionhandleCanvasPointerUp()— end of interaction
Add your tool's behavior in the appropriate switch/if blocks.
Add the shortcut key in packages/excalidraw/actions/shortcuts.ts.
Place it in packages/excalidraw/components/:
packages/excalidraw/components/
├── MyComponent.tsx
└── MyComponent.scss (if needed)
Follow existing patterns:
- Use Jotai atoms from
editor-jotai.tsfor state - Use
useAppStateValue()to read app state - Use SCSS modules for styling (or plain SCSS with BEM-like naming)
- Use
useDevice()from@excalidraw/commonfor responsive behavior
Place it in excalidraw-app/components/:
excalidraw-app/components/
├── MyAppComponent.tsx
└── MyAppComponent.scss
If adding a utility function to one of the core packages:
-
Determine the right package:
- Pure math/geometry →
packages/math/ - Element manipulation →
packages/element/ - Shared utility (no element/UI deps) →
packages/common/ - Export/bounds helpers →
packages/utils/
- Pure math/geometry →
-
Add to the appropriate source file (or create a new module)
-
Export from the package index (
src/index.ts) -
Respect the dependency graph:
commonandmathcannot import from other@excalidrawpackages
All user-initiated state changes should go through the action system. Don't directly modify state from event handlers when an action would be more appropriate.
Always use mutateElement() or create new element objects. Never modify element properties directly.
Use import type { ... } for type-only imports (enforced by ESLint):
import type { ExcalidrawElement } from "@excalidraw/element/types";Editor-level atoms go through editor-jotai.ts. App-level atoms through app-jotai.ts. Never import jotai directly.
- Don't bypass the action system for state changes that should be undoable
- Don't add dependencies to
packages/common/orpackages/math/on other@excalidrawpackages - Don't import from barrel files (
index.ts) when inside the same package - Don't add large dependencies without discussing — the bundle size is monitored (
size-limit.yml) - Don't add browser-specific APIs without fallbacks — the library runs in SSR contexts (Next.js)
- Don't skip snapshot updates — run
yarn test:updateand review diffs - Don't hardcode strings — use the i18n system for user-facing text (see
packages/excalidraw/locales/)
- Types defined (in appropriate
types.ts) - Default values set (in
newElement.tsorappState.ts) - Migration logic added (in
restore.ts) if changing element schema - Action created with
keyTestif it has a shortcut - UI wired up (toolbar, panel, menu)
- Rendering updated if it's visual
- Tests written (unit + integration)
- Snapshots updated (
yarn test:update) - Type checking passes (
yarn test:typecheck) - Linting passes (
yarn fixthenyarn test:code) - Works on both desktop and mobile
- Works with collaboration (if it changes elements)
- Export/import handles the new data (PNG, SVG, JSON)