Features The Excalidraw editor (pip dash package) supports:
- 💯 Free & open-source.
- 🎨 Infinite, canvas-based whiteboard.
- ✍️ Hand-drawn like style.
- 🌓 Dark mode.
- 🏗️ Customizable.
- 📷 Image support.
- 😀 Shape libraries support.
- 👅 Localization (i18n) support.
- 🖼️ Export to PNG, SVG & clipboard.
- 💾 Open format - export drawings as an .excalidraw json file.
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
- ➡️ Arrow-binding & labeled arrows.
- 🔙 Undo / Redo.
- 🔍 Zoom and panning support.
- 🚀 +Many more...
Installation
pip install dash-excalidraw
What's New in 0.1.0
Version 0.1.0 is a full rebuild on Excalidraw 0.18.x with a JSON-safe prop surface — every prop round-trips cleanly through Dash callbacks. There are no function props, no React children, no RegExp values: just JSON.
Props now fall into three groups:
| Kind | Direction | Props |
|---|---|---|
| Declarative | Python → canvas | theme, viewModeEnabled, zenModeEnabled, gridModeEnabled, isCollaborating, UIOptions, validateEmbeddable, interceptLinkOpens, hideExcalidrawLinks, langCode, name |
| Event snapshots (read-only) | canvas → Python | elements, appState, files, serializedData, externalizedSerializedData, sceneVersion, lastPointerDown, lastPointerUp, lastPointerMove, lastScrollChange, lastPaste, lastLibraryChange, lastLinkOpen, lastExport, lastFileAdded, lastExternalDrop |
| Command dispatch | Python → canvas (imperative) | command — write {id, type, payload} from a callback |
Migrating from 0.0.x
| Old (0.0.x) | New (0.1.0) |
|---|---|
onPointerUpdate, onPointerDown, onScrollChange, onPaste, onLibraryChange, onLinkOpen | lastPointerMove, lastPointerDown, lastScrollChange, lastPaste, lastLibraryChange, lastLinkOpen |
excalidrawAPI callback | command prop + lastExport event |
renderTopRightUI, renderCustomStats, renderEmbeddable, generateIdForFile | Removed — function props cannot round-trip through JSON |
isCollaborating defaulted True | Defaults False — opt in explicitly |
gridModeEnabled defaulted True | Defaults False |
height defaulted "400px" | Defaults "600px" |
appState output: {gridSize, viewBackgroundColor} only | Full serializable appState |
validateEmbeddable: RegExp[] | validateEmbeddable: list[str] domain globs (compiled to RegExps internally) |
Introduction
# File: docs/dash_excalidraw/introduction.py
import dash_excalidraw
from dash import Dash, html, dcc, callback, Input, Output, State
import json
import dash_mantine_components as dmc
from dash_ace import DashAceEditor
# _dash_renderer._set_react_version("18.2.0")
class CustomJSONDecoder(json.JSONDecoder):
def decode(self, s):
result = super().decode(s)
return self._decode(result)
def _decode(self, o):
if isinstance(o, bool):
return o
if isinstance(o, dict):
return {k: self._decode(v) for k, v in o.items()}
if isinstance(o, list):
return [self._decode(v) for v in o]
if o == "true":
return True
if o == "false":
return False
if o == "null":
return None
return o
def custom_pprint(obj, indent=2):
def format_value(v):
if isinstance(v, (dict, list)):
return custom_pprint(v, indent)
elif v is True:
return 'True'
elif v is False:
return 'False'
elif v is None:
return 'None'
else:
return repr(v)
if isinstance(obj, dict):
items = [f"{' ' * indent}{repr(k)}: {format_value(v)}" for k, v in obj.items()]
return "{\n" + ",\n".join(items) + "\n}"
elif isinstance(obj, list):
items = [f"{' ' * indent}{format_value(v)}" for v in obj]
return "[\n" + ",\n".join(items) + "\n]"
else:
return repr(obj)
initialCanvasData = {}
component = html.Div([
dmc.Tabs(
[
dmc.TabsList(
[
dmc.TabsTab(
"Dash Excalidraw",
# leftSection=DashIconify(icon="tabler:message"),
value="dashecalidraw-component",
style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
),
dmc.TabsTab(
"DashExcalidraw .json Output",
# leftSection=DashIconify(icon="tabler:settings"),
value="canvas-output",
style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
),
]
),
dmc.TabsPanel(dash_excalidraw.DashExcalidraw(
id='excalidraw',
width='100%',
height='65vh',
initialData=initialCanvasData,
# validateEmbeddable=False,
# isCollaborating=False,
), value="dashecalidraw-component"),
dmc.TabsPanel(html.Div([
html.Div(id='number-of-elements'),
html.Div(id='output')
]), value="canvas-output"),
],
value="dashecalidraw-component",
),
# dcc.Interval(id='interval', interval=1000)
]
)
@callback(
Output('output', 'children'),
Output('number-of-elements', 'children'),
Input('excalidraw', 'serializedData'),
)
def display_output(serializedData):
if not serializedData:
return 'No elements drawn yet', 'Number of elements: 0'
# Parse the serialized data with custom decoder
data = json.loads(serializedData, cls=CustomJSONDecoder)
# Count the number of elements
num_elements = len(data.get('elements', []))
# Use custom pretty-print function
output = custom_pprint(data, indent=2)
# Add a key to force re-rendering
return DashAceEditor(
id='dash-ace-editor',
value=f'{output}',
theme='monokai',
mode='python',
tabSize=2,
enableBasicAutocompletion=True,
enableLiveAutocompletion=True,
autocompleter='/autocompleter?prefix=',
placeholder='Python code ...',
style={'height': '500px', 'width': '80vw'}
), html.Label(f"Number of elements: {num_elements}")
Simple Example
# File: docs/dash_excalidraw/simple_example.py
from dash_excalidraw import DashExcalidraw
from dash import Dash, html, dcc, callback, Input, Output, State
initialCanvasData = {}
component = html.Div([
DashExcalidraw(
id='excalidraw-simple',
width='100%',
height='80vh',
initialData=initialCanvasData,
)
])
Commands and the Export Round-Trip
Anything that used to require the removed excalidrawAPI callback is now an imperative command. Write a dict to the command prop from a Python callback:
@callback(Output("board", "command"), Input("update-btn", "n_clicks"),
prevent_initial_call=True)
def send_command(_):
return {
"id": f"cmd-{uuid.uuid4()}", # MUST be unique — drives de-duplication
"type": "updateScene", # see supported types below
"payload": {...}, # shape depends on type
}
Supported type values:
| Scene mutation | Async export (round-trips via lastExport) | Other |
|---|---|---|
updateScene | exportToSvg | setActiveTool |
resetScene | exportToBlob | setToast |
addFiles | exportToCanvas | toggleSidebar |
replaceFiles | updateLibrary | |
scrollToContent |
Each dispatch is de-duplicated by id, and the component clears the prop once the action completes so React re-renders do not re-fire it.
Exports are async: dispatch the export command in one callback, then observe the result on the lastExport prop ({timestamp, id, type, result, error?}) in a separate callback. Always match on id or type — lastExport holds the result of some export, not necessarily your latest command.
Draw something below, then click Export to SVG:
# File: docs/dash_excalidraw/export_roundtrip.py
"""Export round-trip example for dash-excalidraw 0.1.0.
Demonstrates the imperative `command` prop and the `lastExport` event:
a Python callback dispatches an `exportToSvg` (or `setActiveTool`)
command, and a second callback observes the async result on `lastExport`.
"""
import uuid
import dash_mantine_components as dmc
from dash import Input, Output, callback, ctx, html, no_update
from dash_excalidraw import DashExcalidraw
component = html.Div(
[
DashExcalidraw(
id="excalidraw-export-canvas",
width="100%",
height="50vh",
initialData={
"elements": [],
"appState": {},
"scrollToContent": True,
},
),
dmc.Group(
[
dmc.Button(
"Export to SVG",
id="excalidraw-export-svg-btn",
color="blue",
),
dmc.Button(
"Select Rectangle Tool",
id="excalidraw-export-tool-btn",
variant="light",
color="teal",
),
],
mt="sm",
mb="sm",
),
html.Div(id="excalidraw-export-preview"),
]
)
@callback(
Output("excalidraw-export-canvas", "command"),
Input("excalidraw-export-svg-btn", "n_clicks"),
Input("excalidraw-export-tool-btn", "n_clicks"),
prevent_initial_call=True,
)
def dispatch_command(_svg_clicks, _tool_clicks):
"""Write a {id, type, payload} dict to `command` to act on the canvas.
The `id` must be unique per dispatch — the component de-duplicates
on it, then clears the prop once the action completes.
"""
if ctx.triggered_id == "excalidraw-export-tool-btn":
return {
"id": f"tool-{uuid.uuid4()}",
"type": "setActiveTool",
"payload": {"type": "rectangle"},
}
return {
"id": f"export-svg-{uuid.uuid4()}",
"type": "exportToSvg",
"payload": {"exportPadding": 20},
}
@callback(
Output("excalidraw-export-preview", "children"),
Input("excalidraw-export-canvas", "lastExport"),
prevent_initial_call=True,
)
def render_export(result):
"""Exports are async — observe them in a separate callback.
Always match on `id`/`type`: `lastExport` is the result of *some*
export, not necessarily the latest command you dispatched.
"""
if not result or result.get("type") != "exportToSvg":
return no_update
if result.get("error"):
return dmc.Alert(str(result["error"]), color="red", title="Export failed")
return dmc.Paper(
[
dmc.Text("SVG export result:", size="sm", c="dimmed", mb="xs"),
html.Iframe(
srcDoc=result.get("result", ""),
style={
"width": "100%",
"height": "300px",
"border": "none",
"background": "white",
},
),
],
withBorder=True,
p="sm",
)
Initial Data
initialData seeds the scene on mount with {elements, appState, files, libraryItems, scrollToContent}. It is mount-only — updating the prop after render does nothing. To change the scene after mount, dispatch a command with type="updateScene":
@callback(
Output("canvas", "command"),
Input("restore-btn", "n_clicks"),
State("store", "data"),
prevent_initial_call=True,
)
def restore(_, snapshot):
parsed = json.loads(snapshot)
return {
"id": f"restore-{uuid.uuid4()}",
"type": "updateScene",
"payload": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
},
}
For persistence, stream serializedData into a dcc.Store — but guard against Excalidraw's mount-time empty scene (skip envelopes with no elements) or you'll clobber a valid snapshot on every page refresh. When the scene contains images, prefer persisting externalizedSerializedData, which strips inline base64 data: URIs.
Theme and View Modes
theme ("light" / "dark"), viewModeEnabled, zenModeEnabled, and gridModeEnabled are plain declarative props — set them from any callback and the canvas reflects the change:
# Follow your app's color scheme
clientside_callback(
"function(scheme) { return scheme || 'light'; }",
Output("canvas", "theme"),
Input("color-scheme-store", "data"),
)
viewModeEnabled=True— read-only canvas: drawing tools disabled, pan/zoom still available.zenModeEnabled=True— hides most of the chrome for a distraction-free canvas.gridModeEnabled=True— snap-to-grid plus grid background (now defaults toFalse).
Component Properties
| Property | Type | Default | Description |
|---|---|---|---|
| id | string | - | Unique ID to identify this component in Dash callbacks |
| width | string | '100%' | CSS width of the canvas container |
| height | string | '600px' | CSS height of the canvas container (Excalidraw fills its parent) |
| initialData | dict | - | Initial scene on mount: {elements, appState, files, libraryItems, scrollToContent}. Mount-only — use command: updateScene afterwards |
| command | dict | - | Imperative dispatch: {id, type, payload}. De-duplicated by unique id; cleared after the action completes |
| theme | 'light' / 'dark' | 'light' | Canvas color theme |
| viewModeEnabled | boolean | False | View-only mode: disables drawing tools; pan/zoom still available |
| zenModeEnabled | boolean | False | Zen mode hides most of the chrome |
| gridModeEnabled | boolean | False | Snap to grid and draw the grid background |
| isCollaborating | boolean | False | Renders the collaborator UI; feed appState.collaborators yourself (no transport bundled) |
| UIOptions | dict | - | JSON-serializable subset of Excalidraw UIOptions: canvasActions, tools.image, welcomeScreen, dockedSidebarBreakpoint |
| validateEmbeddable | boolean or list of strings | - | True allow all, False deny all, or domain globs (e.g. ["*.youtube.com"]) compiled to RegExps internally |
| interceptLinkOpens | boolean | False | Prevent default on link opens so Python can handle lastLinkOpen itself |
| hideExcalidrawLinks | boolean | True | Hides Excalidraw's built-in GitHub/Discord/Twitter menu group |
| langCode | string | 'en' | UI language code (e.g. en, fr-FR, zh-CN) |
| name | string | - | Drawing name — appears in the top bar and export filenames |
| autoFocus | boolean | True | Focus the canvas on mount |
| detectScroll | boolean | True | Whether Excalidraw listens to wheel-scroll events on the canvas |
| handleKeyboardGlobally | boolean | True | Keyboard shortcuts work even when the canvas is not focused |
| libraryReturnUrl | string | - | Optional URL appended to the "Browse Library" button |
| pointerMoveThrottleMs | number | 50 | Debounce interval for lastPointerMove writes (ms) |
| scrollThrottleMs | number | 100 | Debounce interval for lastScrollChange writes (ms) |
| elements | list of dicts | - | Current element array (read-only from Python) |
| appState | dict | - | Full serializable app state (read-only from Python) |
| files | dict | - | Binary file entries: image id → {dataURL, mimeType, ...} (read-only) |
| serializedData | string | - | JSON string of the canonical Excalidraw envelope {type, version, source, elements, appState, files} |
| externalizedSerializedData | string | - | Same envelope with inline data: URIs stripped to null — persist this to avoid base64 bloat |
| sceneVersion | number | - | Monotonic scene version — cheap change detection without diffing elements |
| lastExport | dict | - | Result of the most recent export command: {timestamp, id, type, result, error?} |
| lastPointerDown | dict | - | {timestamp, activeTool, pointer: {x, y}} |
| lastPointerUp | dict | - | {timestamp, activeTool, pointer: {x, y}} |
| lastPointerMove | dict | - | Throttled {timestamp, pointer, button, pointersMap} |
| lastScrollChange | dict | - | Throttled {timestamp, scrollX, scrollY} |
| lastPaste | dict | - | {timestamp, data} snapshot of the last clipboard paste |
| lastLibraryChange | dict | - | {timestamp, items} snapshot of the last library change |
| lastLinkOpen | dict | - | {timestamp, elementId, url} — fired on Cmd/Ctrl-click of a linked element |
| lastFileAdded | dict | - | Fires when new files appear with inline data: dataURLs; batch under files, first file at top level |
| lastExternalDrop | dict | - | Fires on drops Excalidraw doesn't accept (non-image or multi-file): {timestamp, files, dropPoint, placeholderIds} |
Helpers
The package also exports file-handling helpers for the image-externalization workflow:
from dash_excalidraw import decode_data_url, strip_inline_files, restore_inline_files
decode_data_url(data_url)— split adata:URI into(mime, raw_bytes)for uploading.strip_inline_files(serialized)— remove inline base64 from a serialized envelope.restore_inline_files(serialized, files)— rehydrate inline bytes into a stripped envelope.