dash-excalidraw — Excalidraw drawing canvas for Dash
The Excalidraw whiteboard as a first-class component for Plotly Dash 3 and 4.
Every prop JSON-serializable · events arrive as timestamped snapshot props · imperative actions via a command/lastExport round-trip · image externalization built in · no clientside_callback required for anything.
Documentation · Discord · YouTube · GitHub
Live at excalidraw.2plot.dev — every canvas on the docs site is a running Dash app.
Maintained by Pip Install Python LLC.
Excalidraw is a React application with a large imperative API. Wrapping it for Dash means answering one question honestly: what crosses the Python/JavaScript bridge?
Only JSON crosses. Functions, RegExps and class instances do not. Most wrappers give up at
that point and hand you a clientside_callback for anything interesting. This one doesn't —
it translates the whole surface into three JSON-safe patterns:
| Upstream shape | What Python sees | Why |
|---|---|---|
Callbacks (onPaste, onPointerUpdate, …) |
Snapshot props — lastPaste, lastPointerMove, … each carrying a timestamp |
A callback cannot be serialized. A record of it firing can. The timestamp lets you dedupe. |
Imperative methods (updateScene, exportToSvg, …) |
command — {id, type, payload}, dispatched once per unique id |
One prop covers twelve methods, and re-renders can't re-fire a command. |
| Async results (exports) | lastExport, keyed by the id you dispatched |
Exports resolve out of order under load. The id is how you correlate. |
validateEmbeddable RegExp |
A list of glob strings, compiled to case-insensitive RegExps on the JS side | A RegExp has no JSON representation. "*.youtube.com" does. |
The result is that a Dash developer writes ordinary @callbacks and never touches
JavaScript.
pip install dash-excalidrawNothing else is required — the Excalidraw bundle, its stylesheet and its UI font ship
inside the wheel as a single self-contained JavaScript file. There is no CDN dependency at
load time, no external_stylesheets entry to add, and no build step for consumers.
from dash import Dash, Input, Output, callback, html
from dash_excalidraw import DashExcalidraw
app = Dash(__name__)
app.layout = html.Div([
DashExcalidraw(id="canvas", height="600px"),
html.Pre(id="count"),
])
@callback(Output("count", "children"), Input("canvas", "elements"))
def show(elements):
return f"{len(elements or [])} elements on the canvas"
if __name__ == "__main__":
app.run(debug=True)Driving the canvas from Python is the same idea in reverse — write a command:
import uuid
from dash import Input, Output, callback
from dash_excalidraw import DashExcalidraw
@callback(
Output("canvas", "command"),
Input("seed", "n_clicks"),
prevent_initial_call=True,
)
def seed(_):
return {
# A NEW id every dispatch. The component dispatches once per unique
# id, so re-sending the same one is a silent no-op.
"id": str(uuid.uuid4()),
"type": "updateScene",
"payload": {
"elements": [
{"type": "rectangle", "x": 100, "y": 100,
"width": 200, "height": 120, "id": "r1"},
],
},
}Thirteen pages, each one a running Dash app you can draw on: basic usage, initialData,
theming, view modes, UIOptions, events, command dispatch, export, persistence, library,
collaboration, file uploads and AI scene generation.
Append /llms.txt to any page URL for the machine-readable Markdown of that page — the
whole site is built to be readable by agents as well as people.
To run the docs site locally:
pip install -r requirements.txt
# markdown2dash pins gunicorn<22, against the CVE-driven gunicorn>=23 floor in
# requirements.txt. pip cannot resolve both, so it installs without its
# dependency graph — every one of its real dependencies is already pinned there.
pip install --no-deps markdown2dash==0.1.2
python run.py38 props. Grouped by what they're for:
| Prop | Type | Notes |
|---|---|---|
width, height |
str |
CSS values. height is the one you'll change. |
initialData |
dict |
{elements, appState, files, libraryItems, scrollToContent}. Mount-only — see the gotcha below. |
Written by the component via setProps on every scene change.
| Prop | What it holds |
|---|---|
elements |
The current element array |
appState |
View background, zoom, scroll, grid/zen mode, theme, active tool |
files |
The binary-file map (fileId → {dataURL, mimeType, …}) |
serializedData |
The canonical Excalidraw JSON envelope, as a string |
externalizedSerializedData |
Same envelope with every inline data: URI stripped to null — persist this one |
sceneVersion |
Cheap change detector; compare instead of diffing elements |
viewModeEnabled, zenModeEnabled, gridModeEnabled, isCollaborating, theme
("light"/"dark"), name, langCode, libraryReturnUrl, detectScroll,
handleKeyboardGlobally, autoFocus, interceptLinkOpens, hideExcalidrawLinks,
plus UIOptions and throttle controls (pointerMoveThrottleMs, scrollThrottleMs).
lastPaste · lastPointerDown · lastPointerMove · lastPointerUp ·
lastScrollChange · lastLibraryChange · lastLinkOpen · lastFileAdded ·
lastExternalDrop · lastExport
Each is a dict carrying a timestamp. lastPointerMove and lastScrollChange are
throttled — at 60 Hz they would otherwise be a callback storm.
Twelve command.type values:
| Category | Types |
|---|---|
| Scene | updateScene · resetScene · scrollToContent |
| Files | addFiles · replaceFiles |
| Tools & UI | setActiveTool · setToast · toggleSidebar · updateLibrary |
Export (async → lastExport) |
exportToSvg · exportToBlob · exportToCanvas |
Excalidraw stores every pasted or dropped image as a base64 dataURL inside the scene.
Leaving them inline bloats serializedData by orders of magnitude — a handful of
screenshots turns a 40 KB scene into a 12 MB one, and you pay that on every callback.
The wrapper supports the full upload-and-swap pattern:
lastFileAddedfires once per new inline file —{fileId, mimeType, dataURL, size}. Push the bytes to your storage layer from an ordinary callback.command: replaceFileswith{fileId: {dataURL: "https://…"}}overwrites Excalidraw's copy in place. The old base64 string is unreferenced and garbage-collected.externalizedSerializedDatais the envelope with every remainingdata:URI stripped. External URLs that step 2 installed survive. Persist this.
dash_excalidraw.helpers ships decode_data_url, strip_inline_files and
restore_inline_files — all pure Python, no dependencies.
The component never makes the network call. It emits the bytes and waits to be told
where they landed, which keeps the wrapper credential-free and backend-agnostic. Working
reference: /file-uploads.
The docs site's /ai-agent page turns a
natural-language prompt into a scene by asking Claude or Gemini for Excalidraw JSON and
dispatching it with command: updateScene. The technique is the interesting part and it
generalizes: the model returns data, not code, so nothing it produces is executed —
a malformed response draws nothing, it cannot do anything.
Two costs worth stating plainly before you copy the pattern:
- Tokens. A scene of any complexity is a few thousand output tokens. The system prompt is stable across requests, so Claude calls use prompt caching — that is most of the saving. Costs are per-provider and change; check current pricing before wiring it to a public form.
- Exposure. An unauthenticated LLM endpoint on a public host is somebody else's free
API credit. Note where the guard has to go: on the docs site the page declares an
authtier, but that tier governs who can read the page. It does not govern who can make it bill — page tiers are path-based and every Dash callback posts to one shared route, and the tier machinery deliberately fails open when auth isn't configured so a misconfigured deploy still serves its docs. The spend check therefore lives inside the callback itself, fails closed in production, and is the thing worth copying. A page-level gate alone would have looked right and protected nothing.
The page runs the model call synchronously inside a Dash callback for clarity. For
production, move it to background=True or a task queue.
| Dash | 3.0.3+ and the whole 4.x line |
| Python | 3.9+ |
| React | 18.3.1 (provided by Dash — not bundled) |
| Excalidraw | 0.18.1, pinned exactly and bundled |
The wheel depends on Dash and nothing else. The documentation site's requirements are
separate and never reach a pip install dash-excalidraw.
Upgraded from 0.17.6. Two changes are worth knowing if you used the earlier release:
commitToHistory became captureUpdate, and the default's meaning changed. 0.17 left
undo history untouched when you passed nothing. 0.18 defaults to EVENTUALLY, which folds
a programmatic scene push into the next captured action — so a user's first Ctrl+Z after
your updateScene would also roll back their own previous edit. Nothing errors and nothing
warns. This wrapper defaults to IMMEDIATELY, so a Python-dispatched push is one
discrete, individually undoable step. Override per dispatch with
payload["captureUpdate"] = "IMMEDIATELY" | "NEVER" | "EVENTUALLY"; peer-driven scenes in a
collaborative app want "NEVER". A legacy commitToHistory is translated and warned about
once.
collaborators moved to top-level SceneData. Either spelling is accepted from Python
and normalised.
0.18 also brings elbow arrows, flowchart shortcuts (Cmd+Arrow), scene search, image cropping, element linking and the command palette.
initialDatais mount-only. Excalidraw owns the scene after mount. Setting the prop from a callback does nothing — usecommand: updateSceneorresetScene.- Commands need unique ids. Dispatching the same
{id, type}twice is a no-op by design. Useuuid.uuid4(). lastExportcarries the id you dispatched. Don't assume the newestlastExportanswers your newest command; async exports arrive out of order.validateEmbeddabletakes glob strings, not regex —"*.youtube.com".
# Python side
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
# JS side (only needed when changing src/ts)
nvm use # .nvmrc pins node 20.11.1
npm install
npm run build # webpack bundle + regenerated Python wrappers
npm run watch # webpack --watch during iteration
# Test
pytest tests -qThe built bundle dash_excalidraw/dash_excalidraw.js is committed on purpose: the
release workflow gates on its commit timestamp, and tracking it keeps pip install git+…
working without a Node toolchain. Rebuild and commit it only in release-prep commits.
See REBUILD.md for the architecture and the reasoning behind the prop
surface.
Part of the 2plot network — component documentation sites, each one a running Dash app: leaflet · email · flexlayout · llms · boilerplate
MIT — see LICENSE.
Excalidraw itself is MIT-licensed by the Excalidraw team. Third-party notices for
everything bundled into the JavaScript artifact ship alongside it in
dash_excalidraw/dash_excalidraw.js.LICENSE.txt.