A Streamlit custom component that embeds the Excalidraw whiteboard editor.
Works in both directions: draw a scene from Python and read back what is on the canvas.
pip install git+https://github.com/gioelemo/streamlit-excalidraw.gitimport streamlit as st
from streamlit_excalidraw import excalidraw_whiteboard
trigger = st.button("Export canvas")
result = excalidraw_whiteboard(height=600, key="my_canvas", trigger_export=trigger)
if result and result["png"]:
st.image(result["png"], caption="Exported canvas")
st.json(result["elements"])import json
elements = json.dumps(
[
{
"type": "rectangle",
"x": 100,
"y": 100,
"width": 200,
"height": 90,
"backgroundColor": "#a5d8ff",
"fillStyle": "solid",
"label": {"text": "Beam", "fontSize": 20},
},
]
)
# Bump the version whenever you want the canvas overwritten; keep it stable
# and reruns will not disturb what the user is drawing.
if st.button("Draw"):
st.session_state.seq = st.session_state.get("seq", 0) + 1
excalidraw_whiteboard(
key="my_canvas",
elements=elements,
scene_version=f"demo:{st.session_state.get('seq', 0)}",
)excalidraw_whiteboard(height=650, key=None, trigger_export=False, elements=None, scene_version=None)
| Parameter | Type | Default | Description |
|---|---|---|---|
height |
int |
650 |
Height of the whiteboard in pixels |
key |
str | None |
None |
Unique key for the component instance |
trigger_export |
bool |
False |
When True, exports the canvas |
elements |
str | None |
None |
JSON array string of Excalidraw elements to draw. Loose skeletons are fine — the component fills in the rest. None leaves the canvas untouched |
scene_version |
str | None |
None |
Opaque token deciding when elements is written — see below |
Returns: dict | None — None until an export is triggered, then:
| Key | Type | Description |
|---|---|---|
png |
str | None |
Base64-encoded PNG data URL, or None if the canvas was empty |
elements |
str |
JSON array string of the current scene ("[]" when empty) |
exported_at |
str |
ISO-8601 timestamp, unique per export — a reliable de-duplication key |
The scene is written only when scene_version changes. Streamlit re-sends
every argument on every rerun, so without this the component would overwrite the
canvas continuously and erase whatever the user was mid-way through drawing.
Prefer a counter-like token (f"{chat_id}:{seq}") over a hash of the content: a
content-derived value re-triggers the write if the user erases back to a scene
that was pushed earlier. When omitted, the elements payload itself is used.
Pushed scenes go into Excalidraw's undo history, so a user can Ctrl-Z a
drawing they did not ask for.
The return value changed from a bare base64 PNG string to a dict.
# 0.1.x
data = excalidraw_whiteboard(trigger_export=trigger)
if data:
st.image(data)
# 0.2.0
result = excalidraw_whiteboard(trigger_export=trigger)
if result and result["png"]:
st.image(result["png"])Note the truthiness check. An empty canvas used to return None; it now returns
a dict with png=None and elements="[]", so that "exported an empty canvas"
is distinguishable from "no export has happened". Test result["png"], not
result.
streamlit_excalidraw/frontend/build/ holds the Vite output and is tracked in
git. That is deliberate: consumers install with
pip install git+https://..., which runs no Node, so the compiled asset has to
be in the repo or the component renders nothing.
The cost is that staleness is invisible — edit src/index.tsx, commit without
rebuilding, and every consumer keeps the old component with no error anywhere.
A check-bundle-rebuilt pre-commit hook guards against this; run
pre-commit install once so it is armed.
cd streamlit_excalidraw/frontend
npm ci # `ci`, not `install` -- package-lock.json is committed
npm run build # rimraf build -> tsc --noEmit -> vite build (~10 min)
cd ../..
git add -A streamlit_excalidraw/frontend/buildUse git add -A: Vite content-hashes the bundle into its filename
(index-<hash>.js), so a rebuild adds a new asset and the old one must be
removed in the same commit.
Two things that make the bundle hard to inspect by hand:
- It is minified with
minifyIdentifiers, so imported bindings are renamed. Grepping it for a symbol likeconvertToExcalidrawElementsreturns 0 whether or not the import is present — that is not evidence of tree-shaking. Only property names (updateScene,scrollToContent) survive verbatim. npm run buildrunstsc --noEmitfirst, so a bad import fails the build rather than shipping quietly. That typecheck is the real guard.
npm run dev is vite build --watch — it rebuilds into build/, it is not
a dev server. Hard-refresh the Streamlit page to pick up each rebuild, and run
Streamlit against this checkout (pip install -e .) rather than an installed
copy.
For a true HMR loop, set _RELEASE = False in streamlit_excalidraw/__init__.py
and start Vite manually — there is no npm script for it and no server block in
vite.config.ts, so the port must be given explicitly to match the URL that
_RELEASE = False points at:
cd streamlit_excalidraw/frontend && npx vite --port 3001 --strictPortRemember to set _RELEASE = True again before committing; nothing checks it.
MIT
